From 893bef356c0bc8755814880fffca19f3cb8549a8 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 16 Oct 2026 19:07:55 +0000 Subject: [PATCH] Upload via GUI (7592 Dateien) --- mobile/.gitignore | 6 + .../DeskHubMobile_release.kotlin_module | Bin 0 -> 126 bytes ...kHubViewModel$uploadSignatureImage$1.class | Bin 0 -> 10757 bytes .../deskhub/staff/ui/DeskHubViewModel.class | Bin 0 -> 69585 bytes .../staff/ui/DeskHubViewModelFactory.class | Bin 0 -> 2081 bytes .../deskhub/staff/ui/DeskHubViewModelKt.class | Bin 0 -> 1948 bytes .../de/deskhub/staff/ui/ThemeController.class | Bin 0 -> 1876 bytes .../de/deskhub/staff/ui/ThemeMode.class | Bin 0 -> 2134 bytes mobile/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + mobile/src/main/AndroidManifest.xml | 26 + .../java/de/deskhub/staff/MainActivity.kt | 23 + .../de/deskhub/staff/data/TicketRepository.kt | 901 +++ .../staff/data/remote/ConnectionMonitor.kt | 14 + .../deskhub/staff/data/remote/DeskHubApi.kt | 594 ++ .../deskhub/staff/data/remote/dto/ApiDtos.kt | 1046 +++ .../staff/data/security/SecureSessionStore.kt | 117 + .../de/deskhub/staff/domain/model/Models.kt | 708 ++ .../java/de/deskhub/staff/ui/DeskHubApp.kt | 6206 +++++++++++++++++ .../de/deskhub/staff/ui/DeskHubViewModel.kt | 1601 +++++ .../java/de/deskhub/staff/ui/Permissions.kt | 12 + .../res/drawable/ic_launcher_background.xml | 21 + .../res/drawable/ic_launcher_foreground.xml | 33 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + mobile/src/main/res/values-night/themes.xml | 9 + mobile/src/main/res/values/strings.xml | 4 + mobile/src/main/res/values/themes.xml | 9 + .../main/res/xml/network_security_config.xml | 8 + .../data/remote/ConnectionMonitorTest.kt | 21 + .../staff/data/remote/DeskHubUrlTest.kt | 29 + .../data/remote/dto/ApiDtoParsingTest.kt | 84 + .../java/de/deskhub/staff/ui/ComposeUiTest.kt | 88 + .../de/deskhub/staff/ui/PermissionsTest.kt | 27 + 34 files changed, 11604 insertions(+) create mode 100644 mobile/.gitignore create mode 100644 mobile/build/tmp/kotlin-classes/release/META-INF/DeskHubMobile_release.kotlin_module create mode 100644 mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel$uploadSignatureImage$1.class create mode 100644 mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel.class create mode 100644 mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelFactory.class create mode 100644 mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelKt.class create mode 100644 mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeController.class create mode 100644 mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeMode.class create mode 100644 mobile/gradle/wrapper/gradle-wrapper.jar create mode 100644 mobile/gradle/wrapper/gradle-wrapper.properties create mode 100644 mobile/src/main/AndroidManifest.xml create mode 100644 mobile/src/main/java/de/deskhub/staff/MainActivity.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/data/remote/ConnectionMonitor.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/ui/Permissions.kt create mode 100644 mobile/src/main/res/drawable/ic_launcher_background.xml create mode 100644 mobile/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 mobile/src/main/res/values-night/themes.xml create mode 100644 mobile/src/main/res/values/strings.xml create mode 100644 mobile/src/main/res/values/themes.xml create mode 100644 mobile/src/main/res/xml/network_security_config.xml create mode 100644 mobile/src/test/java/de/deskhub/staff/data/remote/ConnectionMonitorTest.kt create mode 100644 mobile/src/test/java/de/deskhub/staff/data/remote/DeskHubUrlTest.kt create mode 100644 mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt create mode 100644 mobile/src/test/java/de/deskhub/staff/ui/ComposeUiTest.kt create mode 100644 mobile/src/test/java/de/deskhub/staff/ui/PermissionsTest.kt diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 00000000..d27a9b69 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +*.jks +*.keystore +keystore.properties diff --git a/mobile/build/tmp/kotlin-classes/release/META-INF/DeskHubMobile_release.kotlin_module b/mobile/build/tmp/kotlin-classes/release/META-INF/DeskHubMobile_release.kotlin_module new file mode 100644 index 0000000000000000000000000000000000000000..6454b814cc37c4108468110b9231d9f485722ef9 GIT binary patch literal 126 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

`%FhZl92>%h#>VM} z^Mt1`YrYlobGg+n+xS02Ga%r z`Q8Xt)%_p9;RSk*2Uac$SJ?@yY%kEIh#Q^2DAaS`2=3I&88*Ix5pK@?0xO>ru5vP{ zvMdf)xinH{hdl+dlx*PGs3BQjNuWuKtZx{_45H~>mtG>}k^Z)w{UH2E{KX+!?~M%1 z9)`3H851z>H57NFbcm3eC|x2l$m1Ri5e=fdWa6&K4unkjt&wRUh<~%g5P773gT-Yg zWpA03Uux5{ZHjO!{tXr%+UG$wy~?EGYWuv!K9jYC_xUmQd8~c5{qdPLe{Y-rj7{IQ z`5SCXz@UC@f8u2GEX|w-iLZaf>Z~(&n$T&%4>@ zD*H?qX%HMAv8kmW(P*AUsZEcy=|YoA+b;XNeI9Jni)?D)2|Yyr1|w2!QafI8zj@}z zn^Y__seIl(e_~ShfX%n~U?;INBjfRTtLexWk?P1^V1_ThF6w=1)c0l0TK|_dYyDr= zto46c6JNW`d@{4v|7FeEFTXB|Pks8o6#C`YMd{`4}H>Rq_nQZcy@a#V%FiX(b+2tV*#t zia)3LtBU_Z@n0(byppdeaiNlLDRHI}XKPT5Rs1u>f2a6Oir=dEJ&NC~#0^T^p~Q_! zJfg(oisSpREsC#Ie1qagDBh~LQoK#^cEvX;ex%|@DZW~XU6cSKMkv0!;u91HkS8iW zQi)n6N|dNm{9(mA6@NnUpDX^P;y+RR*GkkYv05XpL5V&}6f1F*5+^7Dbj(&_i4yaa zn4{!Rm3&l1_Ehq56#=#UOtC*J_7^1wD!GG_7by87#qLq^e#IV9>`BFrRUEg8pQYsK zN&vrG6hBUh1C@MMvGWzXP_avt_*k(!6}wBxx0M700Mb7w`JRf5i_phw2f^$Z2A0~9 PxTpr-B0W>{&xHR6#C|JC literal 0 HcmV?d00001 diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelFactory.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelFactory.class new file mode 100644 index 0000000000000000000000000000000000000000..b286b9b0deb8080ba8170692366132272926562a GIT binary patch literal 2081 zcma)6OH&(15dLNbyUQX#mJQ}%2*Eh!y?`IFEg-?z1UcdnP>3;y)Uq02&}x_LtO#6E zRDMQ|IpvsC9FvNx5{hGfR4P4^%u5MnRcU&rce?wV?yvjrfByIrz$Dfg26NobahcyP zZrCz%HaG2}u%D4)uDG!(__y;xj(f9?8wKHk0mhKnarPYBbNnrPabt(Ok%1V)kmKjV zK;-sqPi%7c!1cI&YQ7W(dm_if-cvud4EludMKnbS`Z84*a!%yfS>fh+w9Iz{DOC7W z|0=`nOb~9_J3QJ59pOvc@%YQdpIoTp?aWtaILIV;Eu|9P+ zkhD;P>kRMx#5gQ8*Fi%ILrt_TB*SoZdj7ve^mg41xf5}QvGc%ZJV(k@f99$>k@4h2 z_Q}PxZfw<2HIN^xzJ|^;t)FJl7raZ;B%11@1>9ChjuyoJB}`9^Z1j>2Ry4 zzCYi0`L5!Sfqn)v3d%)Q!TPI99mgPsRAGmSH0RNrqk;k3!T?4U2}}#!7-zV3RqhSk zXSjAQSh-+?X2K=rmO91gr4FS;FtBtSkMLL#Pvf!c-JVbPC7fuE6mg)D_9 zqJy-*$-|K6RIzs7v0TPz9zX%8G|D`W9Myz$ODpWDuS_j<6gp3;eW6aHw9Ab#e0O}< zZE3Aq>3@7!-kPzCN6j=^j}OPSQTDVUsVB5{Z8UcBqpl@tUffC4wrg{Gk7*tsCiDSq z)EJK^%yu(blO*+M-CRrQqP5&uHhX#UeT~vs$A>3B>GcW)rFY~k3-2+5=wU2NgW*ae zy3|~mh4TTWkAEFJc+9&()E9#L%%^u2tIK6H-ulr_V+j z4S^YIDC&-k@cBZqu))J@nF7g7;5y!_6AE>%D0?saI-O$|oHh^}-2A+=TQRF!Q7sk~ zLy8nn`#uj#?Bg_jtd*b`x_p)%&(KzJzgh(pV?@OoVvtTUsfHNszaWn2B!4#@B+kYRM~Y90dC%trmUJv4BOr4r_GQu!L767fJsKYVqkhVrfjL@idJo%+MCk y(s+*9bufv~Ft?7lM4Lof!jfo~FeTb0UPyc{(JApzqC-*+{J$=AOTz7kXb literal 0 HcmV?d00001 diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelKt.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelKt.class new file mode 100644 index 0000000000000000000000000000000000000000..89a33359ac93f8371932e7b8d62a85fd4c727dbf GIT binary patch literal 1948 zcmZuxT~ixX7=F$MlCTXlB(xU#A-1s%sb#6OR!eCc3zZZ?wLlP2>&fPj9J1MEc26k1 zt3RW^pk6w?pp12#su#}qf7A;d$M+-)1Ri;=lIwhvTMl=t*2D6;uxU5K&~G<=U%0C1 zxvI9HxUU!nvLkc*#tX`qZgqko)!J(+=~(x;Bisu2V~8;vs|rj$;gwKv0(MY@skxN}v~gdjB}XP%D8FTgqCHjSWXkaV741euER%eg^X?{jL;? zS1Oe_(z}dWdFDKuAjNQk!B|=#tZf1vXN2G_oa(_z3^GJ-Nmr`d441NpnI3l4Fi+!* zi4@K<#E9>rY!KJf0j?S?WqBLJn$J6wWvzvsAroe4!D*QtA#eg0kkKjLB}vAA*|4Omgzs|4D!95dfwY4dMi@@pHDTB1JvHBSoawC%Uj%{l+!A-1 zg!KT^b^}Zt=B}Au#(O4mxI%?;y(iQT9fIr@p4y>Xc@_SYp>Mw}G_@GsC&z4CvTG*B zah>=y1K}4553drGkxtPUYrgk{mmM*2geGKrsKYB&Vdl_{O`uydH897J2#s3BN#JVx;34&Dc+deLfgAWx`#oal$@sz+ za#Q#ObuR3-qEKARO^}SnW`tfHriUIqfPvNZ#Gu11sb^y}t6XKc_2ya9eDlmSl6|HT zH|QIoinlXO)F50bQPzO=?6X8Qv^vco^kA2|?9#H$F z|Np>^*6BIvx<_vv!|^%kiuq=vEc`{?Rr)E=Z$pXuQm@0!UV7^6dXaC0YjeSC`nI@F zi5LbSHC;s)yd(ojE_v7W6xYQKNKXS@nmwWz(y|lI3av`COQ4b2L1H!+@7hN4HBu|7 zftMJ1g>$)WoZrU9l~i^Iqu-OA0YPIQv}}Kn@1HR77uIN%pg3I^hJ`U&4e1JoIj)D9 z&vr(Bjq#ON_~1Zr)(WoOfWIjORXGgtEhRQ+_gZf5^3S;W9KE?WxVeqV7q~zhcb=o4 zpnkma8wQqBQ!mgLuA$2Z^3kbnLC$DaVcz?Te*ZSJ(W z?C$rQj*Q&BJ*O|6XZyUz9|wLE2A;=59gM*`aF1NabN!C8C_qOHE?RT$De%E3UEVRj@B;YU35UC*fzRb(t$QpY&Mwbnjw zbrb8SZs_(nDKgwC*578aGP2W9PCA4thdeHs$l)@BE_tK~G*Dnj71iMwu9!&UD#P3b z`R0(t^(-{pV3@z?268B1QYlYSzR|vr47bnse_9?ykKTzvGAtCA&$k&A0)t7~jiJFU zG8m!sU8TYB^g^ZT1yP4FlAC8Rsx2zVLG5l056`L1|Lj{|qE>2poPpIC?$=$v9R{NP z(rE>~!$5K;?E8`Eapz3|HAr80BKp4O@9{9?Z6eh?99E7@QM*zP7KT7KdE~a;$R#bU zccf0UY5)c7($-6%R%MdfYYab}9N#px3C+~3lVg+qN=_!tqHQB%=o2T$>spyTTCg*g zHl>wQwr!=SF4<{Y$!g4$$6o)K(JXzt5NpuNAU{)S!?3d3Gw)PS6ijRU^-r0c0<_K) zOnaEg=?j3`<|}#;ecf+v2E+w_9&(Snl1R)a`wtHf?{y=F>Ft=lntvpuAf6wd_AEoT zAv(Sr^+U2uKc>!MxLg-L-|F|8JbV`4i(L;|uD9ccLamd{rMLaQY27mc%tp`;Tl@)O zWSC5BcK(F~Yt%|LBlW>l32M|A&E7baDi|vB&m!#ntmTg zF-y13uw-BnS16o9mZwMkm|{RtzCojgPw;@|ZBi-`kt@4M)v#Q{Y7KXBk3={%l(Du8 uCb5nWcafKvk@!d=D{)<-AaQ@N%t*{i%t_2kEJ%Dj05VQ~@6b~uPyYa{Ma2C8 literal 0 HcmV?d00001 diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeMode.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeMode.class new file mode 100644 index 0000000000000000000000000000000000000000..86f6b538a24e840fec5c2e119c6f74e5e837ad38 GIT binary patch literal 2134 zcma)7ZC4vb6nV{QLrO$80a}2!~sEYl*3-;6ToQ5DmDgsa$0;XQzCPU=BXAK%ULy*wL z;CFTno8iV4NinEVPRU0ty~-InJ)u@F8J12UEX|;lxs&lQugBu&Q5kM!YV~q*k2{6B zZdi6wx2&3@J4TJx8#QO6VVcBUa9=kYoMO5ei|4NlOOECiGmloX45L>mYZ%0kNPC#! zTD~p7&B7ipIw=`%6KzGaW>JP`Q)p>u7{xm(Zs4YjAqLrvbaRJc>VkU2uZ(@V-D=6y zZ9CN#RjV`FnH24p(r_E^i6X}+S~mhJ4spL?Q7Z}FhKc=3%`putDN1I$UE4Y(e(zs$ z{C|2|iLPvHZ>0t0gKx&sa2H8I_BinrJuPW4`PA9bYV-Q!qstKQ{QN=S{)KqVZ*w94;!DArGtEG!-l>n5BMN zZdesg;wKs=F(9<4TYK6Yz~xoO8fEk*;?^7)>kJ)&(br6m#m;%TgnA8|_)NtHJ}3B7 z=GM8Rmvl#`!bsJ9AH7RV3?VUqE>?y1zcqwJL`tOz2EBO@QKf!KmBL}Qc|cQ_Dk-vZ z^25pZQh)PcQi=E@(s=V=oIU7Pq)0F<4NBv_lOKap*uT~DT2jb((60o-!96mkPoXb# z@>7r=52-ZC1bpu@y^X7l!g{S>nEY9tn_RcW{cr6rK7W3<;xG(tH7v)d^0c*Y*hazR zi~nA2D#sGNr3~E}!{Qr_YJu0cbU|~SCn>$mJxij?^5FQi{f;&^MlDBIp8bcI@O*lSjI|R@gwh3T z;oK%mfin<%3280iJH&K0LFV_eiNGOJ*@Wjt!5sY)d*MHEYX~g-3F&EM-WOSTfd@yJ zcEu%sWW^Qlx#Gh>WYrZ@$N2OJBb0FynPY4nVM0`iovG3(?>_9%nS^gpra41eS;E}T z!@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ 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

NnX)EH+Nua)3Y(c z&{(nX_ht=QbJ%DzAya}!&uNu!4V0xI)QE$SY__m)SAKcN0P(&JcoK*Lxr@P zY&P=}&B3*UWNlc|&$Oh{BEqwK2+N2U$4WB7Fd|aIal`FGANUa9E-O)!gV`((ZGCc$ zBJA|FFrlg~9OBp#f7aHodCe{6= zay$6vN~zj1ddMZ9gQ4p32(7wD?(dE>KA2;SOzXRmPBiBc6g`eOsy+pVcHu=;Yd8@{ zSGgXf@%sKKQz~;!J;|2fC@emm#^_rnO0esEn^QxXgJYd`#FPWOUU5b;9eMAF zZhfiZb|gk8aJIw*YLp4!*(=3l8Cp{(%p?ho22*vN9+5NLV0TTazNY$B5L6UKUrd$n zjbX%#m7&F#U?QNOBXkiiWB*_tk+H?N3`vg;1F-I+83{M2!8<^nydGr5XX}tC!10&e z7D36bLaB56WrjL&HiiMVtpff|K%|*{t*ltt^5ood{FOG0<>k&1h95qPio)2`eL${YAGIx(b4VN*~nKn6E~SIQUuRH zQ+5zP6jfnP$S0iJ@~t!Ai3o`X7biohli;E zT#yXyl{bojG@-TGZzpdVDXhbmF%F9+-^YSIv|MT1l3j zrxOFq>gd2%U}?6}8mIj?M zc077Zc9fq(-)4+gXv?Az26IO6eV`RAJz8e3)SC7~>%rlzDwySVx*q$ygTR5kW2ds- z!HBgcq0KON9*8Ff$X0wOq$`T7ml(@TF)VeoF}x1OttjuVHn3~sHrMB++}f7f9H%@f z=|kP_?#+fve@{0MlbkC9tyvQ_R?lRdRJ@$qcB(8*jyMyeME5ns6ypVI1Xm*Zr{DuS zZ!1)rQfa89c~;l~VkCiHI|PCBd`S*2RLNQM8!g9L6?n`^evQNEwfO@&JJRme+uopQX0%Jo zgd5G&#&{nX{o?TQwQvF1<^Cg3?2co;_06=~Hcb6~4XWpNFL!WU{+CK;>gH%|BLOh7@!hsa(>pNDAmpcuVO-?;Bic17R}^|6@8DahH)G z!EmhsfunLL|3b=M0MeK2vqZ|OqUqS8npxwge$w-4pFVXFq$_EKrZY?BuP@Az@(k`L z`ViQBSk`y+YwRT;&W| z2e3UfkCo^uTA4}Qmmtqs+nk#gNr2W4 zTH%hhErhB)pkXR{B!q5P3-OM+M;qu~f>}IjtF%>w{~K-0*jPVLl?Chz&zIdxp}bjx zStp&Iufr58FTQ36AHU)0+CmvaOpKF;W@sMTFpJ`j;3d)J_$tNQI^c<^1o<49Z(~K> z;EZTBaVT%14(bFw2ob@?JLQ2@(1pCdg3S%E4*dJ}dA*v}_a4_P(a`cHnBFJxNobAv zf&Zl-Yt*lhn-wjZsq<9v-IsXxAxMZ58C@e0!rzhJ+D@9^3~?~yllY^s$?&oNwyH!#~6x4gUrfxplCvK#!f z$viuszW>MFEcFL?>ux*((!L$;R?xc*myjRIjgnQX79@UPD$6Dz0jutM@7h_pq z0Zr)#O<^y_K6jfY^X%A-ip>P%3saX{!v;fxT-*0C_j4=UMH+Xth(XVkVGiiKE#f)q z%Jp=JT)uy{&}Iq2E*xr4YsJ5>w^=#-mRZ4vPXpI6q~1aFwi+lQcimO45V-JXP;>(Q zo={U`{=_JF`EQj87Wf}{Qy35s8r1*9Mxg({CvOt}?Vh9d&(}iI-quvs-rm~P;eRA@ zG5?1HO}puruc@S{YNAF3vmUc2B4!k*yi))<5BQmvd3tr}cIs#9)*AX>t`=~{f#Uz0 z0&Nk!7sSZwJe}=)-R^$0{yeS!V`Dh7w{w5rZ9ir!Z7Cd7dwZcK;BT#V0bzTt>;@Cl z#|#A!-IL6CZ@eHH!CG>OO8!%G8&8t4)Ro@}USB*k>oEUo0LsljsJ-%5Mo^MJF2I8- z#v7a5VdJ-Cd%(a+y6QwTmi+?f8Nxtm{g-+WGL>t;s#epv7ug>inqimZCVm!uT5Pf6 ziEgQt7^%xJf#!aPWbuC_3Nxfb&CFbQy!(8ANpkWLI4oSnH?Q3f?0k1t$3d+lkQs{~(>06l&v|MpcFsyAv zin6N!-;pggosR*vV=DO(#+}4ps|5$`udE%Kdmp?G7B#y%H`R|i8skKOd9Xzx8xgR$>Zo2R2Ytktq^w#ul4uicxW#{ zFjG_RNlBroV_n;a7U(KIpcp*{M~e~@>Q#Av90Jc5v%0c>egEdY4v3%|K1XvB{O_8G zkTWLC>OZKf;XguMH2-Pw{BKbFzaY;4v2seZV0>^7Q~d4O=AwaPhP3h|!hw5aqOtT@ z!SNz}$of**Bl3TK209@F=Tn1+mgZa8yh(Png%Zd6Mt}^NSjy)etQrF zme*llAW=N_8R*O~d2!apJnF%(JcN??=`$qs3Y+~xs>L9x`0^NIn!8mMRFA_tg`etw z3k{9JAjnl@ygIiJcNHTy02GMAvBVqEss&t2<2mnw!; zU`J)0>lWiqVqo|ex7!+@0i>B~BSU1A_0w#Ee+2pJx0BFiZ7RDHEvE*ptc9md(B{&+ zKE>TM)+Pd>HEmdJao7U@S>nL(qq*A)#eLOuIfAS@j`_sK0UEY6OAJJ-kOrHG zjHx`g!9j*_jRcJ%>CE9K2MVf?BUZKFHY?EpV6ai7sET-tqk=nDFh-(65rhjtlKEY% z@G&cQ<5BKatfdA1FKuB=i>CCC5(|9TMW%K~GbA4}80I5%B}(gck#Wlq@$nO3%@QP_ z8nvPkJFa|znk>V92cA!K1rKtr)skHEJD;k8P|R8RkCq1Rh^&}Evwa4BUJz2f!2=MH zo4j8Y$YL2313}H~F7@J7mh>u%556Hw0VUOz-Un@ZASCL)y8}4XXS`t1AC*^>PLwIc zUQok5PFS=*#)Z!3JZN&eZ6ZDP^-c@StY*t20JhCnbMxXf=LK#;`4KHEqMZ-Ly9KsS zI2VUJGY&PmdbM+iT)zek)#Qc#_i4uH43 z@T5SZBrhNCiK~~esjsO9!qBpaWK<`>!-`b71Y5ReXQ4AJU~T2Njri1CEp5oKw;Lnm)-Y@Z3sEY}XIgSy%xo=uek(kAAH5MsV$V3uTUsoTzxp_rF=tx zV07vlJNKtJhCu`b}*#m&5LV4TAE&%KtHViDAdv#c^x`J7bg z&N;#I2GkF@SIGht6p-V}`!F_~lCXjl1BdTLIjD2hH$J^YFN`7f{Q?OHPFEM$65^!u zNwkelo*5+$ZT|oQ%o%;rBX$+?xhvjb)SHgNHE_yP%wYkkvXHS{Bf$OiKJ5d1gI0j< zF6N}Aq=(WDo(J{e-uOecxPD>XZ@|u-tgTR<972`q8;&ZD!cep^@B5CaqFz|oU!iFj zU0;6fQX&~15E53EW&w1s9gQQ~Zk16X%6 zjG`j0yq}4deX2?Tr(03kg>C(!7a|b9qFI?jcE^Y>-VhudI@&LI6Qa}WQ>4H_!UVyF z((cm&!3gmq@;BD#5P~0;_2qgZhtJS|>WdtjY=q zLnHH~Fm!cxw|Z?Vw8*~?I$g#9j&uvgm7vPr#&iZgPP~v~BI4jOv;*OQ?jYJtzO<^y z7-#C={r7CO810!^s(MT!@@Vz_SVU)7VBi(e1%1rvS!?PTa}Uv`J!EP3s6Y!xUgM^8 z4f!fq<3Wer_#;u!5ECZ|^c1{|q_lh3m^9|nsMR1#Qm|?4Yp5~|er2?W^7~cl;_r4WSme_o68J9p03~Hc%X#VcX!xAu%1`R!dfGJCp zV*&m47>s^%Ib0~-2f$6oSgn3jg8m%UA;ArcdcRyM5;}|r;)?a^D*lel5C`V5G=c~k zy*w_&BfySOxE!(~PI$*dwG><+-%KT5p?whOUMA*k<9*gi#T{h3DAxzAPxN&Xws8o9Cp*`PA5>d9*Z-ynV# z9yY*1WR^D8|C%I@vo+d8r^pjJ$>eo|j>XiLWvTWLl(^;JHCsoPgem6PvegHb-OTf| zvTgsHSa;BkbG=(NgPO|CZu9gUCGr$8*EoH2_Z#^BnxF0yM~t`|9ws_xZ8X8iZYqh! zAh;HXJ)3P&)Q0(&F>!LN0g#bdbis-cQxyGn9Qgh`q+~49Fqd2epikEUw9caM%V6WgP)532RMRW}8gNS%V%Hx7apSz}tn@bQy!<=lbhmAH=FsMD?leawbnP5BWM0 z5{)@EEIYMu5;u)!+HQWhQ;D3_Cm_NADNeb-f56}<{41aYq8p4=93d=-=q0Yx#knGYfXVt z+kMxlus}t2T5FEyCN~!}90O_X@@PQpuy;kuGz@bWft%diBTx?d)_xWd_-(!LmVrh**oKg!1CNF&LX4{*j|) zIvjCR0I2UUuuEXh<9}oT_zT#jOrJAHNLFT~Ilh9hGJPI1<5`C-WA{tUYlyMeoy!+U zhA#=p!u1R7DNg9u4|QfED-2TuKI}>p#2P9--z;Bbf4Op*;Q9LCbO&aL2i<0O$ByoI z!9;Ght733FC>Pz>$_mw(F`zU?`m@>gE`9_p*=7o=7av`-&ifU(^)UU`Kg3Kw`h9-1 z6`e6+im=|m2v`pN(2dE%%n8YyQz;#3Q-|x`91z?gj68cMrHl}C25|6(_dIGk*8cA3 zRHB|Nwv{@sP4W+YZM)VKI>RlB`n=Oj~Rzx~M+Khz$N$45rLn6k1nvvD^&HtsMA4`s=MmuOJID@$s8Ph4E zAmSV^+s-z8cfv~Yd(40Sh4JG#F~aB>WFoX7ykaOr3JaJ&Lb49=B8Vk-SQT9%7TYhv z?-Pprt{|=Y5ZQ1?od|A<_IJU93|l4oAfBm?3-wk{O<8ea+`}u%(kub(LFo2zFtd?4 zwpN|2mBNywv+d^y_8#<$r>*5+$wRTCygFLcrwT(qc^n&@9r+}Kd_u@Ithz(6Qb4}A zWo_HdBj#V$VE#l6pD0a=NfB0l^6W^g`vm^sta>Tly?$E&{F?TTX~DsKF~poFfmN%2 z4x`Dc{u{Lkqz&y!33;X}weD}&;7p>xiI&ZUb1H9iD25a(gI|`|;G^NwJPv=1S5e)j z;U;`?n}jnY6rA{V^ zxTd{bK)Gi^odL3l989DQlN+Zs39Xe&otGeY(b5>rlIqfc7Ap4}EC?j<{M=hlH{1+d zw|c}}yx88_xQr`{98Z!d^FNH77=u(p-L{W6RvIn40f-BldeF-YD>p6#)(Qzf)lfZj z?3wAMtPPp>vMehkT`3gToPd%|D8~4`5WK{`#+}{L{jRUMt zrFz+O$C7y8$M&E4@+p+oV5c%uYzbqd2Y%SSgYy#xh4G3hQv>V*BnuKQhBa#=oZB~w{azUB+q%bRe_R^ z>fHBilnRTUfaJ201czL8^~Ix#+qOHSO)A|xWLqOxB$dT2W~)e-r9;bm=;p;RjYahB z*1hegN(VKK+ztr~h1}YP@6cfj{e#|sS`;3tJhIJK=tVJ-*h-5y9n*&cYCSdg#EHE# zSIx=r#qOaLJoVVf6v;(okg6?*L_55atl^W(gm^yjR?$GplNP>BZsBYEf_>wM0Lc;T zhf&gpzOWNxS>m+mN92N0{;4uw`P+9^*|-1~$uXpggj4- z^SFc4`uzj2OwdEVT@}Q`(^EcQ_5(ZtXTql*yGzdS&vrS_w>~~ra|Nb5abwf}Y!uq6R5f&6g2ge~2p(%c< z@O)cz%%rr4*cRJ5f`n@lvHNk@lE1a*96Kw6lJ~B-XfJW%?&-y?;E&?1AacU@`N`!O z6}V>8^%RZ7SQnZ-z$(jsX`amu*5Fj8g!3RTRwK^`2_QHe;_2y_n|6gSaGyPmI#kA0sYV<_qOZc#-2BO%hX)f$s-Z3xlI!ub z^;3ru11DA`4heAu%}HIXo&ctujzE2!6DIGE{?Zs>2}J+p&C$rc7gJC35gxhflorvsb%sGOxpuWhF)dL_&7&Z99=5M0b~Qa;Mo!j&Ti_kXW!86N%n= zSC@6Lw>UQ__F&+&Rzv?gscwAz8IP!n63>SP)^62(HK98nGjLY2*e^OwOq`3O|C92? z;TVhZ2SK%9AGW4ZavTB9?)mUbOoF`V7S=XM;#3EUpR+^oHtdV!GK^nXzCu>tpR|89 zdD{fnvCaN^^LL%amZ^}-E+214g&^56rpdc@yv0b<3}Ys?)f|fXN4oHf$six)-@<;W&&_kj z-B}M5U*1sb4)77aR=@%I?|Wkn-QJVuA96an25;~!gq(g1@O-5VGo7y&E_srxL6ZfS z*R%$gR}dyONgju*D&?geiSj7SZ@ftyA|}(*Y4KbvU!YLsi1EDQQCnb+-cM=K1io78o!v*);o<XwjaQH%)uIP&Zm?)Nfbfn;jIr z)d#!$gOe3QHp}2NBak@yYv3m(CPKkwI|{;d=gi552u?xj9ObCU^DJFQp4t4e1tPzM zvsRIGZ6VF+{6PvqsplMZWhz10YwS={?`~O0Ec$`-!klNUYtzWA^f9m7tkEzCy<_nS z=&<(awFeZvt51>@o_~>PLs05CY)$;}Oo$VDO)?l-{CS1Co=nxjqben*O1BR>#9`0^ zkwk^k-wcLCLGh|XLjdWv0_Hg54B&OzCE^3NCP}~OajK-LuRW53CkV~Su0U>zN%yQP zH8UH#W5P3-!ToO-2k&)}nFe`t+mdqCxxAHgcifup^gKpMObbox9LFK;LP3}0dP-UW z?Zo*^nrQ6*$FtZ(>kLCc2LY*|{!dUn$^RW~m9leoF|@Jy|M5p-G~j%+P0_#orRKf8 zvuu5<*XO!B?1E}-*SY~MOa$6c%2cM+xa8}_8x*aVn~57v&W(0mqN1W`5a7*VN{SUH zXz98DDyCnX2EPl-`Lesf`=AQT%YSDb`$%;(jUTrNen$NPJrlpPDP}prI>Ml!r6bCT;mjsg@X^#&<}CGf0JtR{Ecwd&)2zuhr#nqdgHj+g2n}GK9CHuwO zk>oZxy{vcOL)$8-}L^iVfJHAGfwN$prHjYV0ju}8%jWquw>}_W6j~m<}Jf!G?~r5&Rx)!9JNX!ts#SGe2HzobV5); zpj@&`cNcO&q+%*<%D7za|?m5qlmFK$=MJ_iv{aRs+BGVrs)98BlN^nMr{V_fcl_;jkzRju+c-y?gqBC_@J0dFLq-D9@VN&-`R9U;nv$Hg?>$oe4N&Ht$V_(JR3TG^! zzJsbQbi zFE6-{#9{G{+Z}ww!ycl*7rRdmU#_&|DqPfX3CR1I{Kk;bHwF6jh0opI`UV2W{*|nn zf_Y@%wW6APb&9RrbEN=PQRBEpM(N1w`81s=(xQj6 z-eO0k9=Al|>Ej|Mw&G`%q8e$2xVz1v4DXAi8G};R$y)ww638Y=9y$ZYFDM$}vzusg zUf+~BPX>(SjA|tgaFZr_e0{)+z9i6G#lgt=F_n$d=beAt0Sa0a7>z-?vcjl3e+W}+ z1&9=|vC=$co}-Zh*%3588G?v&U7%N1Qf-wNWJ)(v`iO5KHSkC5&g7CrKu8V}uQGcfcz zmBz#Lbqwqy#Z~UzHgOQ;Q-rPxrRNvl(&u6ts4~0=KkeS;zqURz%!-ERppmd%0v>iRlEf+H$yl{_8TMJzo0 z>n)`On|7=WQdsqhXI?#V{>+~}qt-cQbokEbgwV3QvSP7&hK4R{Z{aGHVS3;+h{|Hz z6$Js}_AJr383c_+6sNR|$qu6dqHXQTc6?(XWPCVZv=)D#6_;D_8P-=zOGEN5&?~8S zl5jQ?NL$c%O)*bOohdNwGIKM#jSAC?BVY={@A#c9GmX0=T(0G}xs`-%f3r=m6-cpK z!%waekyAvm9C3%>sixdZj+I(wQlbB4wv9xKI*T13DYG^T%}zZYJ|0$Oj^YtY+d$V$ zAVudSc-)FMl|54n=N{BnZTM|!>=bhaja?o7s+v1*U$!v!qQ%`T-6fBvmdPbVmro&d zk07TOp*KuxRUSTLRrBj{mjsnF8`d}rMViY8j`jo~Hp$fkv9F_g(jUo#Arp;Xw0M$~ zRIN!B22~$kx;QYmOkos@%|5k)!QypDMVe}1M9tZfkpXKGOxvKXB!=lo`p?|R1l=tA zp(1}c6T3Fwj_CPJwVsYtgeRKg?9?}%oRq0F+r+kdB=bFUdVDRPa;E~~>2$w}>O>v=?|e>#(-Lyx?nbg=ckJ#5U6;RT zNvHhXk$P}m9wSvFyU3}=7!y?Y z=fg$PbV8d7g25&-jOcs{%}wTDKm>!Vk);&rr;O1nvO0VrU&Q?TtYVU=ir`te8SLlS zKSNmV=+vF|ATGg`4$N1uS|n??f}C_4Sz!f|4Ly8#yTW-FBfvS48Tef|-46C(wEO_%pPhUC5$-~Y?!0vFZ^Gu`x=m7X99_?C-`|h zfmMM&Y@zdfitA@KPw4Mc(YHcY1)3*1xvW9V-r4n-9ZuBpFcf{yz+SR{ zo$ZSU_|fgwF~aakGr(9Be`~A|3)B=9`$M-TWKipq-NqRDRQc}ABo*s_5kV%doIX7LRLRau_gd@Rd_aLFXGSU+U?uAqh z8qusWWcvgQ&wu{|sRXmv?sl=xc<$6AR$+cl& zFNh5q1~kffG{3lDUdvEZu5c(aAG~+64FxdlfwY^*;JSS|m~CJusvi-!$XR`6@XtY2 znDHSz7}_Bx7zGq-^5{stTRy|I@N=>*y$zz>m^}^{d&~h;0kYiq8<^Wq7Dz0w31ShO^~LUfW6rfitR0(=3;Uue`Y%y@ex#eKPOW zO~V?)M#AeHB2kovn1v=n^D?2{2jhIQd9t|_Q+c|ZFaWt+r&#yrOu-!4pXAJuxM+Cx z*H&>eZ0v8Y`t}8{TV6smOj=__gFC=eah)mZt9gwz>>W$!>b3O;Rm^Ig*POZP8Rl0f zT~o=Nu1J|lO>}xX&#P58%Yl z83`HRs5#32Qm9mdCrMlV|NKNC+Z~ z9OB8xk5HJ>gBLi+m@(pvpw)1(OaVJKs*$Ou#@Knd#bk+V@y;YXT?)4eP9E5{J%KGtYinNYJUH9PU3A}66c>Xn zZ{Bn0<;8$WCOAL$^NqTjwM?5d=RHgw3!72WRo0c;+houoUA@HWLZM;^U$&sycWrFd zE7ekt9;kb0`lps{>R(}YnXlyGY}5pPd9zBpgXeJTY_jwaJGSJQC#-KJqmh-;ad&F- z-Y)E>!&`Rz!HtCz>%yOJ|v(u7P*I$jqEY3}(Z-orn4 zlI?CYKNl`6I){#2P1h)y(6?i;^z`N3bxTV%wNvQW+eu|x=kbj~s8rhCR*0H=iGkSj zk23lr9kr|p7#qKL=UjgO`@UnvzU)`&fI>1Qs7ubq{@+lK{hH* zvl6eSb9%yngRn^T<;jG1SVa)eA>T^XX=yUS@NCKpk?ovCW1D@!=@kn;l_BrG;hOTC z6K&H{<8K#dI(A+zw-MWxS+~{g$tI7|SfP$EYKxA}LlVO^sT#Oby^grkdZ^^lA}uEF zBSj$weBJG{+Bh@Yffzsw=HyChS(dtLE3i*}Zj@~!_T-Ay7z=B)+*~3|?w`Zd)Co2t zC&4DyB!o&YgSw+fJn6`sn$e)29`kUwAc+1MND7YjV%lO;H2}fNy>hD#=gT ze+-aFNpyKIoXY~Vq-}OWPBe?Rfu^{ps8>Xy%42r@RV#*QV~P83jdlFNgkPN=T|Kt7 zV*M`Rh*30&AWlb$;ae130e@}Tqi3zx2^JQHpM>j$6x`#{mu%tZlwx9Gj@Hc92IuY* zarmT|*d0E~vt6<+r?W^UW0&#U&)8B6+1+;k^2|FWBRP9?C4Rk)HAh&=AS8FS|NQaZ z2j!iZ)nbEyg4ZTp-zHwVlfLC~tXIrv(xrP8PAtR{*c;T24ycA-;auWsya-!kF~CWZ zw_uZ|%urXgUbc@x=L=_g@QJ@m#5beS@6W195Hn7>_}z@Xt{DIEA`A&V82bc^#!q8$ zFh?z_Vn|ozJ;NPd^5uu(9tspo8t%&-U9Ckay-s@DnM*R5rtu|4)~e)`z0P-sy?)kc zs_k&J@0&0!q4~%cKL)2l;N*T&0;mqX5T{Qy60%JtKTQZ-xb%KOcgqwJmb%MOOKk7N zgq})R_6**{8A|6H?fO+2`#QU)p$Ei2&nbj6TpLSIT^D$|`TcSeh+)}VMb}LmvZ{O| ze*1IdCt3+yhdYVxcM)Q_V0bIXLgr6~%JS<<&dxIgfL=Vnx4YHuU@I34JXA|+$_S3~ zy~X#gO_X!cSs^XM{yzDGNM>?v(+sF#<0;AH^YrE8smx<36bUsHbN#y57K8WEu(`qHvQ6cAZPo=J5C(lSmUCZ57Rj6cx!e^rfaI5%w}unz}4 zoX=nt)FVNV%QDJH`o!u9olLD4O5fl)xp+#RloZlaA92o3x4->?rB4`gS$;WO{R;Z3>cG3IgFX2EA?PK^M}@%1%A;?f6}s&CV$cIyEr#q5;yHdNZ9h{| z-=dX+a5elJoDo?Eq&Og!nN6A)5yYpnGEp}?=!C-V)(*~z-+?kY1Q7qs#Rsy%hu_60rdbB+QQNr?S1 z?;xtjUv|*E3}HmuNyB9aFL5H~3Ho0UsmuMZELp1a#CA1g`P{-mT?BchuLEtK}!QZ=3AWakRu~?f9V~3F;TV`5%9Pcs_$gq&CcU}r8gOO zC2&SWPsSG{&o-LIGTBqp6SLQZPvYKp$$7L4WRRZ0BR$Kf0I0SCFkqveCp@f)o8W)! z$%7D1R`&j7W9Q9CGus_)b%+B#J2G;l*FLz#s$hw{BHS~WNLODV#(!u_2Pe&tMsq={ zdm7>_WecWF#D=?eMjLj=-_z`aHMZ=3_-&E8;ibPmM}61i6J3is*=dKf%HC>=xbj4$ zS|Q-hWQ8T5mWde6h@;mS+?k=89?1FU<%qH9B(l&O>k|u_aD|DY*@~(`_pb|B#rJ&g zR0(~(68fpUPz6TdS@4JT5MOPrqDh5_H(eX1$P2SQrkvN8sTxwV>l0)Qq z0pzTuvtEAKRDkKGhhv^jk%|HQ1DdF%5oKq5BS>szk-CIke{%js?~%@$uaN3^Uz6Wf z_iyx{bZ(;9y4X&>LPV=L=d+A}7I4GkK0c1Xts{rrW1Q7apHf-))`BgC^0^F(>At1* za@e7{lq%yAkn*NH8Q1{@{lKhRg*^TfGvv!Sn*ed*x@6>M%aaqySxR|oNadYt1mpUZ z6H(rupHYf&Z z29$5g#|0MX#aR6TZ$@eGxxABRKakDYtD%5BmKp;HbG_ZbT+=81E&=XRk6m_3t9PvD zr5Cqy(v?gHcYvYvXkNH@S#Po~q(_7MOuCAB8G$a9BC##gw^5mW16cML=T=ERL7wsk zzNEayTG?mtB=x*wc@ifBCJ|irFVMOvH)AFRW8WE~U()QT=HBCe@s$dA9O!@`zAAT) zaOZ7l6vyR+Nk_OOF!ZlZmjoImKh)dxFbbR~z(cMhfeX1l7S_`;h|v3gI}n9$sSQ>+3@AFAy9=B_y$)q;Wdl|C-X|VV3w8 z2S#>|5dGA8^9%Bu&fhmVRrTX>Z7{~3V&0UpJNEl0=N32euvDGCJ>#6dUSi&PxFW*s zS`}TB>?}H(T2lxBJ!V#2taV;q%zd6fOr=SGHpoSG*4PDaiG0pdb5`jelVipkEk%FV zThLc@Hc_AL1#D&T4D=w@UezYNJ%0=f3iVRuVL5H?eeZM}4W*bomebEU@e2d`M<~uW zf#Bugwf`VezG|^Qbt6R_=U0}|=k;mIIakz99*>FrsQR{0aQRP6ko?5<7bkDN8evZ& zB@_KqQG?ErKL=1*ZM9_5?Pq%lcS4uLSzN(Mr5=t6xHLS~Ym`UgM@D&VNu8e?_=nSFtF$u@hpPSmI4Vo_t&v?>$~K4y(O~Rb*(MFy_igM7 z*~yYUyR6yQgzWnWMUgDov!!g=lInM+=lOmOk4L`O?{i&qxy&D*_qorRbDwj6?)!ef z#JLd7F6Z2I$S0iYI={rZNk*<{HtIl^mx=h>Cim*04K4+Z4IJtd*-)%6XV2(MCscPiw_a+y*?BKbTS@BZ3AUao^%Zi#PhoY9Vib4N>SE%4>=Jco0v zH_Miey{E;FkdlZSq)e<{`+S3W=*ttvD#hB8w=|2aV*D=yOV}(&p%0LbEWH$&@$X3x~CiF-?ejQ*N+-M zc8zT@3iwkdRT2t(XS`d7`tJQAjRmKAhiw{WOqpuvFp`i@Q@!KMhwKgsA}%@sw8Xo5Y=F zhRJZg)O4uqNWj?V&&vth*H#je6T}}p_<>!Dr#89q@uSjWv~JuW(>FqoJ5^ho0%K?E z9?x_Q;kmcsQ@5=}z@tdljMSt9-Z3xn$k)kEjK|qXS>EfuDmu(Z8|(W?gY6-l z@R_#M8=vxKMAoi&PwnaIYw2COJM@atcgfr=zK1bvjW?9B`-+Voe$Q+H$j!1$Tjn+* z&LY<%)L@;zhnJlB^Og6I&BOR-m?{IW;tyYC%FZ!&Z>kGjHJ6cqM-F z&19n+e1=9AH1VrVeHrIzqlC`w9=*zfmrerF?JMzO&|Mmv;!4DKc(sp+jy^Dx?(8>1 zH&yS_4yL7m&GWX~mdfgH*AB4{CKo;+egw=PrvkTaoBU+P-4u?E|&!c z)DKc;>$$B6u*Zr1SjUh2)FeuWLWHl5TH(UHWkf zLs>7px!c5n;rbe^lO@qlYLzlDVp(z?6rPZel=YB)Uv&n!2{+Mb$-vQl=xKw( zve&>xYx+jW_NJh!FV||r?;hdP*jOXYcLCp>DOtJ?2S^)DkM{{Eb zS$!L$e_o0(^}n3tA1R3-$SNvgBq;DOEo}fNc|tB%%#g4RA3{|euq)p+xd3I8^4E&m zFrD%}nvG^HUAIKe9_{tXB;tl|G<%>yk6R;8L2)KUJw4yHJXUOPM>(-+jxq4R;z8H#>rnJy*)8N+$wA$^F zN+H*3t)eFEgxLw+Nw3};4WV$qj&_D`%ADV2%r zJCPCo%{=z7;`F98(us5JnT(G@sKTZ^;2FVitXyLe-S5(hV&Ium+1pIUB(CZ#h|g)u zSLJJ<@HgrDiA-}V_6B^x1>c9B6%~847JkQ!^KLZ2skm;q*edo;UA)~?SghG8;QbHh z_6M;ouo_1rq9=x$<`Y@EA{C%6-pEV}B(1#sDoe_e1s3^Y>n#1Sw;N|}8D|s|VPd+g z-_$QhCz`vLxxrVMx3ape1xu3*wjx=yKSlM~nFgkNWb4?DDr*!?U)L_VeffF<+!j|b zZ$Wn2$TDv3C3V@BHpSgv3JUif8%hk%OsGZ=OxH@8&4`bbf$`aAMchl^qN>Eyu3JH} z9-S!x8-s4fE=lad%Pkp8hAs~u?|uRnL48O|;*DEU! zuS0{cpk%1E0nc__2%;apFsTm0bKtd&A0~S3Cj^?72-*Owk3V!ZG*PswDfS~}2<8le z5+W^`Y(&R)yVF*tU_s!XMcJS`;(Tr`J0%>p=Z&InR%D3@KEzzI+-2)HK zuoNZ&o=wUC&+*?ofPb0a(E6(<2Amd6%uSu_^-<1?hsxs~0K5^f(LsGqgEF^+0_H=uNk9S0bb!|O8d?m5gQjUKevPaO+*VfSn^2892K~%crWM8+6 z25@V?Y@J<9w%@NXh-2!}SK_(X)O4AM1-WTg>sj1{lj5@=q&dxE^9xng1_z9w9DK>| z6Iybcd0e zyi;Ew!KBRIfGPGytQ6}z}MeXCfLY0?9%RiyagSp_D1?N&c{ zyo>VbJ4Gy`@Fv+5cKgUgs~na$>BV{*em7PU3%lloy_aEovR+J7TfQKh8BJXyL6|P8un-Jnq(ghd!_HEOh$zlv2$~y3krgeH;9zC}V3f`uDtW(%mT#944DQa~^8ZI+zAUu4U(j0YcDfKR$bK#gvn_{JZ>|gZ5+)u?T$w7Q%F^;!Wk?G z(le7r!ufT*cxS}PR6hIVtXa)i`d$-_1KkyBU>qmgz-=T};uxx&sKgv48akIWQ89F{ z0XiY?WM^~;|T8zBOr zs#zuOONzH?svv*jokd5SK8wG>+yMC)LYL|vLqm^PMHcT=`}V$=nIRHe2?h)8WQa6O zPAU}d`1y(>kZiP~Gr=mtJLMu`i<2CspL|q2DqAgAD^7*$xzM`PU4^ga`ilE134XBQ z99P(LhHU@7qvl9Yzg$M`+dlS=x^(m-_3t|h>S}E0bcFMn=C|KamQ)=w2^e)35p`zY zRV8X?d;s^>Cof2SPR&nP3E+-LCkS0J$H!eh8~k0qo$}00b=7!H_I2O+Ro@3O$nPdm ztmbOO^B+IHzQ5w>@@@J4cKw5&^_w6s!s=H%&byAbUtczPQ7}wfTqxxtQNfn*u73Qw zGuWsrky_ajPx-5`R<)6xHf>C(oqGf_Fw|-U*GfS?xLML$kv;h_pZ@Kk$y0X(S+K80 z6^|z)*`5VUkawg}=z`S;VhZhxyDfrE0$(PMurAxl~<>lfZa>JZ288ULK7D` zl9|#L^JL}Y$j*j`0-K6kH#?bRmg#5L3iB4Z)%iF@SqT+Lp|{i`m%R-|ZE94Np7Pa5 zCqC^V3}B(FR340pmF*qaa}M}+h6}mqE~7Sh!9bDv9YRT|>vBNAqv09zXHMlcuhKD| zcjjA(b*XCIwJ33?CB!+;{)vX@9xns_b-VO{i0y?}{!sdXj1GM8+$#v>W7nw;+O_9B z_{4L;C6ol?(?W0<6taGEn1^uG=?Q3i29sE`RfYCaV$3DKc_;?HsL?D_fSYg}SuO5U zOB_f4^vZ_x%o`5|C@9C5+o=mFy@au{s)sKw!UgC&L35aH(sgDxRE2De%(%OT=VUdN ziVLEmdOvJ&5*tCMKRyXctCwQu_RH%;m*$YK&m;jtbdH#Ak~13T1^f89tn`A%QEHWs~jnY~E}p_Z$XC z=?YXLCkzVSK+Id`xZYTegb@W8_baLt-Fq`Tv|=)JPbFsKRm)4UW;yT+J`<)%#ue9DPOkje)YF2fsCilK9MIIK>p*`fkoD5nGfmLwt)!KOT+> zOFq*VZktDDyM3P5UOg`~XL#cbzC}eL%qMB=Q5$d89MKuN#$6|4gx_Jt0Gfn8w&q}%lq4QU%6#jT*MRT% zrLz~C8FYKHawn-EQWN1B75O&quS+Z81(zN)G>~vN8VwC+e+y(`>HcxC{MrJ;H1Z4k zZWuv$w_F0-Ub%MVcpIc){4PGL^I7M{>;hS?;eH!;gmcOE66z3;Z1Phqo(t zVP(Hg6q#0gIKgsg7L7WE!{Y#1nI(45tx2{$34dDd#!Z0NIyrm)HOn5W#7;f4pQci# zDW!FI(g4e668kI9{2+mLwB+=#9bfqgX%!B34V-$wwSN(_cm*^{y0jQtv*4}eO^sOV z*9xoNvX)c9isB}Tgx&ZRjp3kwhTVK?r9;n!x>^XYT z@Q^7zp{rkIs{2mUSE^2!Gf6$6;j~&4=-0cSJJDizZp6LTe8b45;{AKM%v99}{{FfC zz709%u0mC=1KXTo(=TqmZQ;c?$M3z(!xah>aywrj40sc2y3rKFw4jCq+Y+u=CH@_V zxz|qeTwa>+<|H%8Dz5u>ZI5MmjTFwXS-Fv!TDd*`>3{krWoNVx$<133`(ftS?ZPyY z&4@ah^3^i`vL$BZa>O|Nt?ucewzsF)0zX3qmM^|waXr=T0pfIb0*$AwU=?Ipl|1Y; z*Pk6{C-p4MY;j@IJ|DW>QHZQJcp;Z~?8(Q+Kk3^0qJ}SCk^*n4W zu9ZFwLHUx-$6xvaQ)SUQcYd6fF8&x)V`1bIuX@>{mE$b|Yd(qomn3;bPwnDUc0F=; zh*6_((%bqAYQWQ~odER?h>1mkL4kpb3s7`0m@rDKGU*oyF)$j~Ffd4fXV$?`f~rHf zB%Y)@5SXZvfwm10RY5X?TEo)PK_`L6qgBp=#>fO49$D zDq8Ozj0q6213tV5Qq=;fZ0$|KroY{Dz=l@lU^J)?Ko@ti20TRplXzphBi>XGx4bou zEWrkNjz0t5j!_ke{g5I#PUlEU$Km8g8TE|XK=MkU@PT4T><2OVamoK;wJ}3X0L$vX zgd7gNa359*nc)R-0!`2X@FOTB`+oETOPc=ubp5R)VQgY+5BTZZJ2?9QwnO=dnulIUF3gFn;BODC2)65)HeVd%t86sL7Rv^Y+nbn+&l z6BAJY(ETvwI)Ts$aiE8rht4KD*qNyE{8{x6R|%akbTBzw;2+6Echkt+W+`u^XX z_z&x%n + + + + + + + + + + + + + diff --git a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt new file mode 100644 index 00000000..ef234487 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt @@ -0,0 +1,23 @@ +package de.deskhub.staff + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.lifecycle.viewmodel.compose.viewModel +import de.deskhub.staff.data.TicketRepository +import de.deskhub.staff.data.security.SecureSessionStore +import de.deskhub.staff.ui.DeskHubApp +import de.deskhub.staff.ui.DeskHubViewModel +import de.deskhub.staff.ui.DeskHubViewModelFactory + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val repository = TicketRepository(SecureSessionStore(applicationContext)) + val factory = DeskHubViewModelFactory(repository) + setContent { + val viewModel: DeskHubViewModel = viewModel(factory = factory) + DeskHubApp(viewModel) + } + } +} diff --git a/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt new file mode 100644 index 00000000..b5b27305 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt @@ -0,0 +1,901 @@ +package de.deskhub.staff.data + +import android.os.Build +import de.deskhub.staff.data.remote.DeskHubApi +import de.deskhub.staff.data.remote.DeskHubUrl +import de.deskhub.staff.data.remote.dto.AuditLogEntryDto +import de.deskhub.staff.data.remote.dto.BanEntryDto +import de.deskhub.staff.data.remote.dto.BusinessHolidayDto +import de.deskhub.staff.data.remote.dto.BusinessHourDto +import de.deskhub.staff.data.remote.dto.ChatContactDto +import de.deskhub.staff.data.remote.dto.ChatMessageDto +import de.deskhub.staff.data.remote.dto.KeyLabelDto +import de.deskhub.staff.data.remote.dto.LiveChatAttachmentDto +import de.deskhub.staff.data.remote.dto.LiveChatListItemDto +import de.deskhub.staff.data.remote.dto.LiveChatMessageDto +import de.deskhub.staff.data.remote.dto.TeamMemberDto +import de.deskhub.staff.data.remote.dto.TicketRuleActionDto +import de.deskhub.staff.data.remote.dto.TicketRuleConditionDto +import de.deskhub.staff.data.remote.dto.TicketRuleDto +import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest +import de.deskhub.staff.data.remote.dto.CannedResponseDto +import de.deskhub.staff.data.remote.dto.CreateTicketRequest +import de.deskhub.staff.data.remote.dto.DepartmentDto +import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest +import de.deskhub.staff.data.remote.dto.EmailAddressDto +import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest +import de.deskhub.staff.data.remote.dto.EmailTemplateDto +import de.deskhub.staff.data.remote.dto.HelpTopicDto +import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest +import de.deskhub.staff.data.remote.dto.OrganizationDto +import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest +import de.deskhub.staff.data.remote.dto.PriorityFullDto +import de.deskhub.staff.data.remote.dto.PrioritySaveRequest +import de.deskhub.staff.data.remote.dto.RoleDto +import de.deskhub.staff.data.remote.dto.RoleSaveRequest +import de.deskhub.staff.data.remote.dto.StaffAdminDto +import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest +import de.deskhub.staff.data.remote.dto.StaffExtraDeptDto +import de.deskhub.staff.data.remote.dto.TaskCreateRequest +import de.deskhub.staff.data.remote.dto.TaskDto +import de.deskhub.staff.data.remote.dto.TeamDto +import de.deskhub.staff.data.remote.dto.TeamSaveRequest +import de.deskhub.staff.data.remote.dto.CustomerDetailDto +import de.deskhub.staff.data.remote.dto.CustomerDto +import de.deskhub.staff.data.remote.dto.DepartmentStatDto +import de.deskhub.staff.data.remote.dto.EscalationRuleDto +import de.deskhub.staff.data.remote.dto.EscalationSaveRequest +import de.deskhub.staff.data.remote.dto.KbArticleDto +import de.deskhub.staff.data.remote.dto.KbCategoryDto +import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest +import de.deskhub.staff.data.remote.dto.LoginRequest +import de.deskhub.staff.data.remote.dto.MessageRequest +import de.deskhub.staff.data.remote.dto.NamedOptionDto +import de.deskhub.staff.data.remote.dto.PriorityDto +import de.deskhub.staff.data.remote.dto.StaffDto +import de.deskhub.staff.data.remote.dto.StatusOptionDto +import de.deskhub.staff.data.remote.dto.TicketDetailDto +import de.deskhub.staff.data.remote.dto.TicketSummaryDto +import de.deskhub.staff.data.remote.dto.TimeEntryDto +import de.deskhub.staff.data.remote.dto.TimeEntryTotalDto +import de.deskhub.staff.data.security.SecureSessionStore +import de.deskhub.staff.data.security.StoredSession +import de.deskhub.staff.domain.model.ActivityUpdate +import de.deskhub.staff.domain.model.AuditLogEntry +import de.deskhub.staff.domain.model.AuditLogPage +import de.deskhub.staff.domain.model.BanEntry +import de.deskhub.staff.domain.model.BusinessHoliday +import de.deskhub.staff.domain.model.BusinessHour +import de.deskhub.staff.domain.model.BusinessHoursData +import de.deskhub.staff.domain.model.AvailabilityData +import de.deskhub.staff.domain.model.ChatContact +import de.deskhub.staff.domain.model.ChatConversation +import de.deskhub.staff.domain.model.ChatMessage +import de.deskhub.staff.domain.model.KeyLabel +import de.deskhub.staff.domain.model.LiveChatAttachment +import de.deskhub.staff.domain.model.LiveChatDetail +import de.deskhub.staff.domain.model.LiveChatMessage +import de.deskhub.staff.domain.model.LiveChatSummary +import de.deskhub.staff.domain.model.TeamMember +import de.deskhub.staff.domain.model.TicketRule +import de.deskhub.staff.domain.model.TicketRuleAction +import de.deskhub.staff.domain.model.TicketRuleCondition +import de.deskhub.staff.domain.model.TicketRulesData +import de.deskhub.staff.domain.model.CannedResponse +import de.deskhub.staff.domain.model.Customer +import de.deskhub.staff.domain.model.CustomerDetail +import de.deskhub.staff.domain.model.CustomerProfile +import de.deskhub.staff.domain.model.DashboardStats +import de.deskhub.staff.domain.model.Department +import de.deskhub.staff.domain.model.DepartmentStat +import de.deskhub.staff.domain.model.DepartmentData +import de.deskhub.staff.domain.model.DepartmentEmailOption +import de.deskhub.staff.domain.model.HealthIssue +import de.deskhub.staff.domain.model.DepartmentInput +import de.deskhub.staff.domain.model.DepartmentMember +import de.deskhub.staff.domain.model.EmailAddress +import de.deskhub.staff.domain.model.EmailAddressInput +import de.deskhub.staff.domain.model.EmailAddressData +import de.deskhub.staff.domain.model.EmailTemplate +import de.deskhub.staff.domain.model.EscalationData +import de.deskhub.staff.domain.model.EscalationRule +import de.deskhub.staff.domain.model.HelpTopic +import de.deskhub.staff.domain.model.HelpTopicInput +import de.deskhub.staff.domain.model.HelpTopicData +import de.deskhub.staff.domain.model.KbArticle +import de.deskhub.staff.domain.model.KbCategory +import de.deskhub.staff.domain.model.KbData +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.NamedOption +import de.deskhub.staff.domain.model.Organization +import de.deskhub.staff.domain.model.Priority +import de.deskhub.staff.domain.model.PriorityFull +import de.deskhub.staff.domain.model.Role +import de.deskhub.staff.domain.model.RoleData +import de.deskhub.staff.domain.model.SpamActionResult +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StaffAdmin +import de.deskhub.staff.domain.model.StaffAdminData +import de.deskhub.staff.domain.model.StaffAdminInput +import de.deskhub.staff.domain.model.StaffExtraDept +import de.deskhub.staff.domain.model.StaffTask +import de.deskhub.staff.domain.model.StatusOption +import de.deskhub.staff.domain.model.Team +import de.deskhub.staff.domain.model.TeamData +import de.deskhub.staff.domain.model.ThreadAttachment +import de.deskhub.staff.domain.model.ThreadComment +import de.deskhub.staff.domain.model.TicketDetail +import de.deskhub.staff.domain.model.TicketFilters +import de.deskhub.staff.domain.model.TicketPage +import de.deskhub.staff.domain.model.TicketSummary +import de.deskhub.staff.domain.model.TicketThread +import de.deskhub.staff.domain.model.TimeEntriesReport +import de.deskhub.staff.domain.model.TimeEntry +import de.deskhub.staff.domain.model.TimeEntryTotal + +sealed interface LoginResult { + data object TotpRequired : LoginResult + data class Success(val staff: Staff) : LoginResult +} + +class TicketRepository( + private val sessionStore: SecureSessionStore, +) { + private var session: StoredSession? = null + private var api: DeskHubApi? = null + + fun restoreSession(): StoredSession? { + val stored = sessionStore.load() ?: return null + configure(stored) + return stored + } + + /** Zuletzt verwendete Server-Adresse, falls (noch) keine aktive Sitzung besteht – erspart erneutes Eintippen. */ + fun lastServerUrl(): String? = sessionStore.loadLastServerUrl() + + suspend fun login( + serverUrl: String, + email: String, + password: String, + totpCode: String?, + ): LoginResult { + val normalizedUrl = DeskHubUrl.normalize(serverUrl) + sessionStore.saveLastServerUrl(normalizedUrl) + val loginApi = DeskHubApi(normalizedUrl) { null } + val response = loginApi.login( + LoginRequest( + email = email.trim(), + password = password, + totpCode = totpCode?.takeIf { it.isNotBlank() }, + deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(), + ), + ) + if (response.requiresTotp) return LoginResult.TotpRequired + val token = requireNotNull(response.token) { "Die Anmeldung lieferte kein Token." } + val expiresAt = requireNotNull(response.expiresAt) { "Das Token hat kein Ablaufdatum." } + val staffDto = requireNotNull(response.staff) { "Die Anmeldung lieferte kein Mitarbeiterprofil." } + val stored = StoredSession(normalizedUrl, token, expiresAt, staffDto.name) + sessionStore.save(stored) + configure(stored) + return LoginResult.Success(staffDto.toDomain()) + } + + suspend fun validateSession(): Staff = requireApi().me().staff.toDomain() + + suspend fun metadata(): Metadata { + val response = requireApi().metadata() + return Metadata( + staff = response.staff.toDomain(), + departments = response.departments.map(NamedOptionDto::toDomain), + priorities = response.priorities.map(PriorityDto::toDomain), + agents = response.agents.map(NamedOptionDto::toDomain), + statuses = response.statuses.map(StatusOptionDto::toDomain), + tags = response.tags.map(NamedOptionDto::toDomain), + liveChatEnabled = response.liveChatEnabled, + ) + } + + suspend fun tickets(filters: TicketFilters, page: Int = 1): TicketPage { + val query = buildMap { + put("page", page.toString()) + put("perPage", "30") + put("status", filters.statuses.joinToString(",")) + if (filters.query.isNotBlank()) put("q", filters.query.trim()) + if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) + filters.departmentId?.let { put("departmentId", it.toString()) } + filters.priorityId?.let { put("priorityId", it.toString()) } + filters.tagId?.let { put("tagId", it.toString()) } + if (filters.sla.isNotBlank()) put("sla", filters.sla) + } + val response = requireApi().tickets(query) + return TicketPage( + items = response.items.map(TicketSummaryDto::toDomain), + page = response.page, + total = response.total, + hasMore = response.hasMore, + ) + } + + suspend fun ticket(number: String): TicketDetail = + requireApi().ticket(number).ticket.toDomain() + + suspend fun updateTicket( + number: String, + status: String?, + assignedStaffId: Int?, + updateAssignment: Boolean, + ): TicketSummary { + val request = linkedMapOf("number" to number) + status?.let { request["status"] = it } + if (updateAssignment) request["assignedStaffId"] = assignedStaffId + return requireApi().updateTicket(request).ticket.toDomain() + } + + suspend fun setTicketSpam(number: String, restore: Boolean): SpamActionResult { + val response = requireApi().ticketSpam(number, if (restore) "restore" else "mark") + return SpamActionResult( + ticket = response.ticket.toDomain(), + moved = response.moved, + failed = response.failed, + ) + } + + suspend fun addMessage(number: String, type: String, message: String, setStatus: String?) { + requireApi().addMessage(MessageRequest(number, type, message, setStatus)) + } + + suspend fun cannedResponses(): List = + requireApi().cannedResponses().items.map(CannedResponseDto::toDomain) + + suspend fun dashboard(): DashboardStats { + val response = requireApi().dashboard() + return DashboardStats( + myOpen = response.myOpen, + overdue = response.overdue, + unassigned = response.unassigned, + unanswered = response.unanswered, + resolvedToday = response.resolvedToday, + slaPercent = response.slaPercent, + recent = response.recent.map(TicketSummaryDto::toDomain), + departments = response.departments?.map(DepartmentStatDto::toDomain), + ) + } + + suspend fun customers(query: String): List = + requireApi().customers(query).items.map(CustomerDto::toDomain) + + suspend fun customerProfile(userId: Int?, email: String?): CustomerProfile { + val response = requireApi().customer(userId, email) + return CustomerProfile( + customer = response.customer.toDomain(), + tickets = response.tickets.map(TicketSummaryDto::toDomain), + ) + } + + suspend fun createTicket( + requesterName: String, + requesterEmail: String, + subject: String, + message: String, + departmentId: Int, + priorityId: Int, + assignedStaffId: Int?, + notifyCustomer: Boolean, + ): TicketSummary { + val request = CreateTicketRequest( + requesterName = requesterName, + requesterEmail = requesterEmail, + subject = subject, + message = message, + departmentId = departmentId, + priorityId = priorityId, + assignedStaffId = assignedStaffId, + notifyCustomer = notifyCustomer, + ) + return requireApi().createTicket(request).ticket.toDomain() + } + + suspend fun activitySince(since: String): ActivityUpdate { + val response = requireApi().activity(since) + return ActivityUpdate(response.newReplies, response.newUnassigned, response.serverTime, response.health.map { HealthIssue(it.level, it.message) }) + } + + suspend fun logout() { + requireApi().logout() + clearSession() + } + + // ─── Admin: Wissensdatenbank ───────────────────────────────────────────── + + suspend fun kbData(): KbData { + val response = requireApi().kb() + return KbData( + categories = response.categories.map(KbCategoryDto::toDomain), + articles = response.articles.map(KbArticleDto::toDomain), + ) + } + + suspend fun kbSaveArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean): Int = + requireApi().kbSaveArticle(KbSaveArticleRequest(id = id, title = title, categoryId = categoryId, body = body, isPublished = isPublished)).id + + suspend fun kbDeleteArticle(id: Int) = requireApi().kbDeleteArticle(id) + + suspend fun kbCreateCategory(name: String): Int = requireApi().kbCreateCategory(name).id + + suspend fun kbDeleteCategory(id: Int) = requireApi().kbDeleteCategory(id) + + // ─── Admin: Zeiterfassung ──────────────────────────────────────────────── + + suspend fun timeEntries(staffId: Int?, from: String, to: String): TimeEntriesReport { + val query = buildMap { + put("from", from) + put("to", to) + staffId?.let { put("staffId", it.toString()) } + } + val response = requireApi().timeEntries(query) + return TimeEntriesReport( + staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), + entries = response.entries.map(TimeEntryDto::toDomain), + totalsByStaff = response.totalsByStaff.map(TimeEntryTotalDto::toDomain), + totalMinutes = response.totalMinutes, + ) + } + + // ─── Admin: Eskalationsregeln ──────────────────────────────────────────── + + suspend fun escalationData(): EscalationData { + val response = requireApi().escalationRules() + return EscalationData( + priorities = response.priorities.map(NamedOptionDto::toDomain), + staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), + departments = response.departments.map(NamedOptionDto::toDomain), + rules = response.rules.map(EscalationRuleDto::toDomain), + ) + } + + suspend fun escalationSave( + id: Int?, + name: String, + isActive: Boolean, + triggerType: String, + triggerHours: Int, + conditionStatus: String?, + conditionPriorityId: Int?, + conditionDeptId: Int?, + actionSetPriorityId: Int?, + actionAssignStaffId: Int?, + actionSetStatus: String?, + actionNotifyAdmin: Boolean, + actionNotifyStaff: Boolean, + sortOrder: Int, + ): Int = requireApi().escalationSave( + EscalationSaveRequest( + id = id, + name = name, + isActive = isActive, + triggerType = triggerType, + triggerHours = triggerHours, + conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, + conditionDeptId = conditionDeptId, + actionSetPriorityId = actionSetPriorityId, + actionAssignStaffId = actionAssignStaffId, + actionSetStatus = actionSetStatus, + actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, + sortOrder = sortOrder, + ), + ).id + + suspend fun escalationDelete(id: Int) = requireApi().escalationDelete(id) + + suspend fun escalationToggle(id: Int) = requireApi().escalationToggle(id) + + // ─── Admin: Sperrliste ──────────────────────────────────────────────────── + + suspend fun banlist(): List = requireApi().banlist().items.map(BanEntryDto::toDomain) + + suspend fun banCreate(matchType: String, value: String, reason: String?) = + requireApi().banCreate(matchType, value, reason?.ifBlank { null }) + + suspend fun banBulkAdd(values: String, reason: String?): Pair { + val response = requireApi().banBulkAdd(values, reason?.ifBlank { null }) + return response.added to response.skipped + } + + suspend fun banDelete(id: Int) = requireApi().banDelete(id) + + // ─── Admin: Abteilungen ─────────────────────────────────────────────────── + + suspend fun departmentData(): DepartmentData { + val r = requireApi().departments() + return DepartmentData( + departments = r.items.map(DepartmentDto::toDomain), + emailOptions = r.emailOptions.map { DepartmentEmailOption(it.email, it.departmentName) }, + staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), + slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), + ) + } + + suspend fun departmentSave(id: Int?, input: DepartmentInput): Int = + requireApi().departmentSave( + DepartmentSaveRequest( + id = id, name = input.name, email = input.email, isDefault = input.isDefault, signature = input.signature, + isActive = input.isActive, managerId = input.managerId, slaPlanId = input.slaPlanId, notes = input.notes, + ), + ).id + + suspend fun departmentDelete(id: Int) = requireApi().departmentDelete(id) + + // ─── Admin: Prioritäten ─────────────────────────────────────────────────── + + suspend fun report(config: de.deskhub.staff.data.remote.dto.ReportConfigDto?, savedId: Int?): de.deskhub.staff.data.remote.dto.ReportResponseDto { + val query = buildMap { + if (savedId != null) { + put("saved", savedId.toString()) + } else if (config != null) { + put("dimension", config.dimension) + put("metrics", config.metrics.joinToString(",")) + if (config.from.isNotBlank()) put("from", config.from) + if (config.to.isNotBlank()) put("to", config.to) + put("sort", config.sort) + if (config.desc) put("desc", "1") + } + } + return requireApi().report(query) + } + + suspend fun reportSave(name: String, shared: Boolean, config: de.deskhub.staff.data.remote.dto.ReportConfigDto): Int = + requireApi().reportSave(de.deskhub.staff.data.remote.dto.ReportSaveRequest(name = name, isShared = shared, config = config)).id + + suspend fun reportDelete(id: Int) = requireApi().reportDelete(id) + + suspend fun savedViews(): List = requireApi().savedViews().items.map { dto -> + val f = dto.filters + de.deskhub.staff.domain.model.SavedView( + id = dto.id, name = dto.name, isShared = dto.isShared, mine = dto.mine, ownerName = dto.ownerName, + filters = TicketFilters( + query = f["q"].orEmpty(), + statuses = f["status"].orEmpty().split(',').filter { it.isNotBlank() }.toSet().let { s -> + if (s.containsAll(setOf("open", "pending", "answered", "on_hold", "resolved", "closed"))) emptySet() else s + }, + assigned = f["assigned"].orEmpty(), + departmentId = f["departmentId"]?.toIntOrNull(), + priorityId = f["priorityId"]?.toIntOrNull(), + sla = f["sla"].orEmpty(), + ), + ) + } + + suspend fun savedViewSave(name: String, shared: Boolean, filters: TicketFilters): Int { + val map = buildMap { + if (filters.query.isNotBlank()) put("q", filters.query) + put("status", (if (filters.statuses.isEmpty()) setOf("open", "pending", "answered", "on_hold", "resolved", "closed") else filters.statuses).joinToString(",")) + if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) + filters.departmentId?.let { put("departmentId", it.toString()) } + filters.priorityId?.let { put("priorityId", it.toString()) } + if (filters.sla.isNotBlank()) put("sla", filters.sla) + } + return requireApi().savedViewSave(de.deskhub.staff.data.remote.dto.SavedViewSaveRequest(name = name, isShared = shared, filters = map)).id + } + + suspend fun savedViewDelete(id: Int) = requireApi().savedViewDelete(id) + + suspend fun slaPlans(): List = + requireApi().slaPlans().items.map { de.deskhub.staff.domain.model.SlaPlan(it.id, it.name, it.responseHours, it.resolutionHours, it.isActive, it.notes, it.departmentCount, it.helpTopicCount) } + + suspend fun slaPlanSave(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String): Int = + requireApi().slaPlanSave(de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest(id = id, name = name, responseHours = responseHours, resolutionHours = resolutionHours, isActive = isActive, notes = notes)).id + + suspend fun slaPlanDelete(id: Int) = requireApi().slaPlanDelete(id) + + suspend fun prioritiesAdmin(): List = requireApi().prioritiesAdmin().items.map(PriorityFullDto::toDomain) + + suspend fun prioritySave(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int): Int = + requireApi().prioritySave( + PrioritySaveRequest(id = id, name = name, color = color, responseHours = responseHours, resolutionHours = resolutionHours, sortOrder = sortOrder), + ).id + + suspend fun priorityDelete(id: Int) = requireApi().priorityDelete(id) + + // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── + + suspend fun helpTopics(): HelpTopicData { + val r = requireApi().helpTopics() + return HelpTopicData( + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), + topics = r.items.map(HelpTopicDto::toDomain), + ) + } + + suspend fun helpTopicSave(id: Int?, input: HelpTopicInput): Int = + requireApi().helpTopicSave( + HelpTopicSaveRequest( + id = id, name = input.name, departmentId = input.departmentId, priorityId = input.priorityId, isActive = input.isActive, + isPublic = input.isPublic, parentId = input.parentId, slaPlanId = input.slaPlanId, notes = input.notes, sortOrder = input.sortOrder, + ), + ).id + + suspend fun helpTopicDelete(id: Int) = requireApi().helpTopicDelete(id) + + // ─── Admin: Organisationen ──────────────────────────────────────────────── + + suspend fun organizations(): List = requireApi().organizations().items.map(OrganizationDto::toDomain) + + suspend fun organizationSave(id: Int?, name: String, notes: String?): Int = + requireApi().organizationSave(OrganizationSaveRequest(id = id, name = name, notes = notes)).id + + suspend fun organizationDelete(id: Int) = requireApi().organizationDelete(id) + + // ─── Admin: Teams ───────────────────────────────────────────────────────── + + suspend fun teams(): TeamData { + val r = requireApi().teams() + return TeamData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), teams = r.items.map(TeamDto::toDomain)) + } + + suspend fun teamSave(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List): Int = + requireApi().teamSave(TeamSaveRequest(id = id, name = name, leadId = leadId, notes = notes, memberIds = memberIds)).id + + suspend fun teamToggle(id: Int) = requireApi().teamToggle(id) + + suspend fun teamDelete(id: Int) = requireApi().teamDelete(id) + + // ─── Admin: Rollen ──────────────────────────────────────────────────────── + + suspend fun roles(): RoleData { + val r = requireApi().roles() + return RoleData( + permissionKeys = r.permissionKeys, + defaultAgentPermissions = r.defaultAgentPermissions, + roles = r.items.map(RoleDto::toDomain), + ) + } + + suspend fun roleSave(id: Int?, name: String, permissions: Map): Int = + requireApi().roleSave(RoleSaveRequest(id = id, name = name, permissions = permissions)).id + + suspend fun roleDelete(id: Int) = requireApi().roleDelete(id) + + // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── + + suspend fun staffAdminList(): StaffAdminData { + val r = requireApi().staffAdminList() + return StaffAdminData( + departments = r.departments.map(NamedOptionDto::toDomain), + roles = r.roles.map(NamedOptionDto::toDomain), + staff = r.items.map(StaffAdminDto::toDomain), + teams = r.teams.map(NamedOptionDto::toDomain), + canManageTeams = r.canManageTeams, + ) + } + + suspend fun staffAdminSave(id: Int?, input: StaffAdminInput): Int = requireApi().staffAdminSave( + StaffAdminSaveRequest( + id = id, name = input.name, email = input.email, role = input.role, roleId = input.roleId, + departmentId = input.departmentId, isActive = input.isActive, password = input.password, signature = input.signature, + extraDepartments = input.extraDepartments.map { StaffExtraDeptDto(it.departmentId, it.notify, it.roleId) }, + teamIds = input.teamIds, + ), + ).id + + suspend fun staffAdminDelete(id: Int) = requireApi().staffAdminDelete(id) + + // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── + + suspend fun businessHours(): BusinessHoursData { + val r = requireApi().businessHours() + return BusinessHoursData( + enabled = r.enabled, + hours = r.hours.map(BusinessHourDto::toDomain), + holidays = r.holidays.map(BusinessHolidayDto::toDomain), + ) + } + + suspend fun businessHoursSave(enabled: Boolean, hours: List) = + requireApi().businessHoursSave(enabled, hours.map { BusinessHourDto(it.weekday, it.isOpen, it.openTime, it.closeTime) }) + + suspend fun businessHolidayAdd(date: String, name: String): Int = requireApi().businessHolidayAdd(date, name).id + + suspend fun businessHolidayDelete(id: Int) = requireApi().businessHolidayDelete(id) + + // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── + + suspend fun emailAddresses(): EmailAddressData { + val r = requireApi().emailAddresses() + return EmailAddressData( + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), + addresses = r.items.map(EmailAddressDto::toDomain), + ) + } + + suspend fun emailAddressSave(id: Int?, input: EmailAddressInput): Int = + requireApi().emailAddressSave( + EmailAddressSaveRequest( + id = id, email = input.email, label = input.label, departmentId = input.departmentId, priorityId = input.priorityId, isDefault = input.isDefault, + helpTopicId = input.helpTopicId, isActive = input.isActive, notes = input.notes, + smtpEnabled = input.smtpEnabled, smtpHost = input.smtpHost, smtpPort = input.smtpPort, smtpEncryption = input.smtpEncryption, + smtpUsername = input.smtpUsername, smtpPassword = input.smtpPassword, + imapEnabled = input.imapEnabled, imapHost = input.imapHost, imapPort = input.imapPort, imapEncryption = input.imapEncryption, + imapUsername = input.imapUsername, imapPassword = input.imapPassword, imapFolder = input.imapFolder, imapAfterFetch = input.imapAfterFetch, + imapArchiveFolder = input.imapArchiveFolder, imapSpamFolder = input.imapSpamFolder, + ), + ).id + + /** action: "sendTest" (Test-Mail an die eigene Adresse) oder "testImap" (Postfach-Verbindung). Liefert die Servermeldung. */ + suspend fun emailAddressAction(action: String, id: Int): String = requireApi().emailAddressAction(action, id).message + + suspend fun emailAddressDelete(id: Int) = requireApi().emailAddressDelete(id) + + // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── + + suspend fun emailTemplates(): List = requireApi().emailTemplates().items.map(EmailTemplateDto::toDomain) + + suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) = requireApi().emailTemplateSave(key, subject, bodyText) + + suspend fun emailTemplateReset(key: String) = requireApi().emailTemplateReset(key) + + // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── + + suspend fun auditLog(page: Int): AuditLogPage { + val r = requireApi().auditLog(page) + return AuditLogPage(items = r.items.map(AuditLogEntryDto::toDomain), page = r.page, hasMore = r.hasMore) + } + + // ─── Aufgaben ───────────────────────────────────────────────────────────── + + suspend fun tasks(filter: String): de.deskhub.staff.domain.model.TaskData { + val r = requireApi().tasks(filter) + return de.deskhub.staff.domain.model.TaskData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), tasks = r.items.map(TaskDto::toDomain)) + } + + suspend fun taskCreate(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?): Int = + requireApi().taskCreate( + TaskCreateRequest(title = title, description = description, assignedToId = assignedToId, dueAt = dueAt, ticketNumber = ticketNumber), + ).id + + suspend fun taskUpdateStatus(id: Int, status: String) = requireApi().taskUpdateStatus(id, status) + + suspend fun taskDelete(id: Int) = requireApi().taskDelete(id) + + // ─── Interner Chat ──────────────────────────────────────────────────────── + + suspend fun chatContacts(): List = requireApi().chatContacts().contacts.map(ChatContactDto::toDomain) + + suspend fun chatConversation(withId: Int): ChatConversation { + val r = requireApi().chatConversation(withId) + return ChatConversation(with = r.with.toDomain(), messages = r.messages.map(ChatMessageDto::toDomain)) + } + + suspend fun chatSend(toId: Int, message: String): Int = requireApi().chatSend(toId, message).id + + // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── + + suspend fun liveChatList(): List = + requireApi().liveChatList().items.map(LiveChatListItemDto::toDomain) + + suspend fun liveChatMessages(number: String): LiveChatDetail { + val r = requireApi().liveChatMessages(number, 0) + return LiveChatDetail( + number = number, + status = r.status, + requesterName = r.requesterName, + requesterEmail = r.requesterEmail, + departmentName = r.departmentName, + lockedByOtherName = r.lockedByOtherName, + customerTyping = r.customerTyping, + messages = r.messages.map(LiveChatMessageDto::toDomain), + ) + } + + suspend fun liveChatSend(number: String, message: String) { requireApi().liveChatSend(number, message) } + + suspend fun liveChatTyping(number: String) { requireApi().liveChatTyping(number) } + + suspend fun liveChatEnd(number: String) { requireApi().liveChatEnd(number) } + + // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── + + suspend fun availability(): AvailabilityData { + val r = requireApi().availability() + return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain)) + } + + suspend fun setAvailability(status: String, note: String) { requireApi().setAvailability(status, note) } + + // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── + + suspend fun ticketRules(): TicketRulesData { + val r = requireApi().ticketRules() + return TicketRulesData( + fieldOptions = r.fieldOptions.map(KeyLabelDto::toDomain), + operatorOptions = r.operatorOptions.map(KeyLabelDto::toDomain), + actionOptions = r.actionOptions.map(KeyLabelDto::toDomain), + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), + staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), + cannedResponses = r.cannedResponses.map(NamedOptionDto::toDomain), + rules = r.rules.map(TicketRuleDto::toDomain), + autoCloseEnabled = r.autoCloseEnabled, + autoCloseDays = r.autoCloseDays, + ) + } + + suspend fun saveTicketRule( + id: Int?, + name: String, + matchType: String, + isActive: Boolean, + sortOrder: Int, + conditions: List, + actions: List, + ): Int { + val request = TicketRuleSaveRequest( + id = id, + name = name, + matchType = matchType, + isActive = isActive, + sortOrder = sortOrder, + conditions = conditions.map { TicketRuleConditionDto(it.field, it.operator, it.value) }, + actions = actions.map { TicketRuleActionDto(it.type, it.value, it.valueLabel) }, + ) + return requireApi().saveTicketRule(request).id + } + + suspend fun toggleTicketRule(id: Int) { requireApi().toggleTicketRule(id) } + + suspend fun deleteTicketRule(id: Int) { requireApi().deleteTicketRule(id) } + + suspend fun saveAutoClose(enabled: Boolean, days: Int) { requireApi().saveAutoClose(enabled, days) } + + // ─── Bild-Upload ────────────────────────────────────────────────────────── + + suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): String = + requireApi().uploadImage(bytes, filename, mimeType).url + + fun clearSession() { + sessionStore.clear() + session = null + api = null + } + + private fun configure(stored: StoredSession) { + session = stored + api = DeskHubApi(stored.serverUrl) { session?.token } + } + + private fun requireApi(): DeskHubApi = + checkNotNull(api) { "Keine aktive deskhub-Sitzung." } +} + +private fun StaffDto.toDomain() = Staff(id, name, email, role, permissions) +private fun NamedOptionDto.toDomain() = NamedOption(id, name) +private fun PriorityDto.toDomain() = Priority(id, name, color) +private fun StatusOptionDto.toDomain() = StatusOption(value, label, color) +private fun CannedResponseDto.toDomain() = CannedResponse(id, title, body, departmentId) +private fun DepartmentStatDto.toDomain() = DepartmentStat(name, open, overdue) +private fun CustomerDto.toDomain() = Customer(id, name, email, organizationName, ticketCount, isGuest) +private fun CustomerDetailDto.toDomain() = CustomerDetail(id, name, email, organizationName, isGuest, createdAt) +private fun BanEntryDto.toDomain() = BanEntry(id, matchType, value, reason, addedByName, createdAt) +private fun DepartmentDto.toDomain() = Department( + id, name, email, isDefault, signature, isActive, managerId, managerName, slaPlanId, notes, + members.map { DepartmentMember(it.id, it.name, it.isPrimary) }, updatedAt, +) +private fun PriorityFullDto.toDomain() = PriorityFull(id, name, color, responseHours, resolutionHours, sortOrder) +private fun HelpTopicDto.toDomain() = HelpTopic(id, name, departmentId, departmentName, priorityId, priorityName, priorityColor, isActive, sortOrder, isPublic, parentId, slaPlanId, path.ifBlank { name }, notes, updatedAt) +private fun OrganizationDto.toDomain() = Organization(id, name, notes, memberCount) +private fun TeamDto.toDomain() = Team(id, name, leadId, leadName, notes, isActive, memberCount, memberIds) +private fun RoleDto.toDomain() = Role(id, name, permissions, staffCount) +private fun StaffAdminDto.toDomain() = StaffAdmin( + id, name, email, role, roleId, departmentId, departmentName, isActive, signature, + extraDepartments.map { StaffExtraDept(it.departmentId, it.notify, it.roleId) }, + teamIds, createdAt, +) +private fun BusinessHourDto.toDomain() = BusinessHour(weekday, isOpen, openTime, closeTime) +private fun BusinessHolidayDto.toDomain() = BusinessHoliday(id, date, name) +private fun EmailAddressDto.toDomain() = EmailAddress( + id, email, label, departmentId, departmentName, priorityId, priorityName, isDefault, + helpTopicId, isActive, notes, smtpEnabled, smtpHost, smtpPort, smtpEncryption, smtpUsername, smtpHasPassword, + imapEnabled, imapHost, imapPort, imapEncryption, imapUsername, imapFolder, imapAfterFetch, imapArchiveFolder, imapSpamFolder, imapHasPassword, imapLastRunAt, +) +private fun EmailTemplateDto.toDomain() = EmailTemplate(key, label, placeholders, subject, body, isCustomized) +private fun AuditLogEntryDto.toDomain() = AuditLogEntry(id, staffName, action, createdAt) +private fun TaskDto.toDomain() = StaffTask(id, title, description, status, assignedToId, assignedName, creatorName, dueAt, ticketNumber, ticketSubject) +private fun ChatContactDto.toDomain() = ChatContact(id, name, unreadCount, lastMessageAt) +private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, message, createdAt) +private fun LiveChatListItemDto.toDomain() = LiveChatSummary(number, name, preview, needsReply, updatedAt) +private fun LiveChatAttachmentDto.toDomain() = LiveChatAttachment(id, name, size) +private fun LiveChatMessageDto.toDomain() = LiveChatMessage(id, authorType, authorName, message, createdAt, attachments.map(LiveChatAttachmentDto::toDomain)) +private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt) +private fun KeyLabelDto.toDomain() = KeyLabel(value, label) +private fun TicketRuleConditionDto.toDomain() = TicketRuleCondition(field, operator, value) +private fun TicketRuleActionDto.toDomain() = TicketRuleAction(type, value, valueLabel) +private fun TicketRuleDto.toDomain() = TicketRule( + id, name, matchType, isActive, sortOrder, + conditions.map(TicketRuleConditionDto::toDomain), + actions.map(TicketRuleActionDto::toDomain), +) +private fun KbCategoryDto.toDomain() = KbCategory(id, name) +private fun KbArticleDto.toDomain() = KbArticle(id, categoryId, categoryName, title, body, isPublished, views) +private fun TimeEntryDto.toDomain() = TimeEntry(id, staffName, ticketNumber, subject, minutes, note, loggedAt) +private fun TimeEntryTotalDto.toDomain() = TimeEntryTotal(staffName, minutes) +private fun EscalationRuleDto.toDomain() = EscalationRule( + id = id, + name = name, + isActive = isActive, + triggerType = triggerType, + triggerHours = triggerHours, + conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, + conditionPriorityName = conditionPriorityName, + conditionDeptId = conditionDeptId, + conditionDeptName = conditionDeptName, + actionSetPriorityId = actionSetPriorityId, + actionSetPriorityName = actionSetPriorityName, + actionAssignStaffId = actionAssignStaffId, + actionAssignStaffName = actionAssignStaffName, + actionSetStatus = actionSetStatus, + actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, + sortOrder = sortOrder, + appliedCount = appliedCount, +) + +private fun TicketSummaryDto.toDomain() = TicketSummary( + id = id, + number = number, + subject = subject, + requesterName = requesterName, + requesterEmail = requesterEmail, + status = status, + priority = priority.toDomain(), + department = department.toDomain(), + assignedStaff = assignedStaff?.toDomain(), + isOverdue = isOverdue, + dueLabel = dueLabel, + isSpam = isSpam, + createdAt = createdAt, + updatedAt = updatedAt, +) + +private fun TicketDetailDto.toDomain() = TicketDetail( + summary = TicketSummary( + id = id, + number = number, + subject = subject, + requesterName = requesterName, + requesterEmail = requesterEmail, + status = status, + priority = priority.toDomain(), + department = department.toDomain(), + assignedStaff = assignedStaff?.toDomain(), + isOverdue = isOverdue, + isSpam = isSpam, + createdAt = createdAt, + updatedAt = updatedAt, + ), + source = source, + firstResponseDueAt = firstResponseDueAt, + resolutionDueAt = resolutionDueAt, + lockedByOtherName = lockedByOtherName, + threads = threads.map { thread -> + TicketThread( + id = thread.id, + authorType = thread.authorType, + authorName = thread.authorName, + message = thread.message, + isInternalNote = thread.isInternalNote, + createdAt = thread.createdAt, + attachments = thread.attachments.map { ThreadAttachment(it.id, it.name, it.size) }, + comments = thread.comments.map { + ThreadComment(it.id, it.message, it.staff.toDomain(), it.createdAt) + }, + ) + }, + permissions = permissions, +) diff --git a/mobile/src/main/java/de/deskhub/staff/data/remote/ConnectionMonitor.kt b/mobile/src/main/java/de/deskhub/staff/data/remote/ConnectionMonitor.kt new file mode 100644 index 00000000..35e991f6 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/ConnectionMonitor.kt @@ -0,0 +1,14 @@ +package de.deskhub.staff.data.remote + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Merkt sich, ob der Server gerade erreichbar ist (wird von jedem API-Aufruf aktualisiert). */ +object ConnectionMonitor { + private val mutableOffline = MutableStateFlow(false) + val offline: StateFlow = mutableOffline + + fun set(isOffline: Boolean) { + mutableOffline.value = isOffline + } +} diff --git a/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt b/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt new file mode 100644 index 00000000..8f0756ba --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt @@ -0,0 +1,594 @@ +package de.deskhub.staff.data.remote + +import com.google.gson.GsonBuilder +import de.deskhub.staff.data.remote.dto.ActivityResponseDto +import de.deskhub.staff.data.remote.dto.CannedResponseListResponse +import de.deskhub.staff.data.remote.dto.CreateTicketRequest +import de.deskhub.staff.data.remote.dto.CreateTicketResponse +import de.deskhub.staff.data.remote.dto.CustomerListResponse +import de.deskhub.staff.data.remote.dto.CustomerProfileResponse +import de.deskhub.staff.data.remote.dto.AuditLogResponse +import de.deskhub.staff.data.remote.dto.BanBulkAddRequest +import de.deskhub.staff.data.remote.dto.BanBulkAddResponse +import de.deskhub.staff.data.remote.dto.BanCreateRequest +import de.deskhub.staff.data.remote.dto.BanDeleteRequest +import de.deskhub.staff.data.remote.dto.BanlistResponse +import de.deskhub.staff.data.remote.dto.BusinessHolidayAddRequest +import de.deskhub.staff.data.remote.dto.ChatContactsResponse +import de.deskhub.staff.data.remote.dto.ChatConversationResponse +import de.deskhub.staff.data.remote.dto.ChatSendRequest +import de.deskhub.staff.data.remote.dto.ChatSendResponse +import de.deskhub.staff.data.remote.dto.AutoCloseSaveRequest +import de.deskhub.staff.data.remote.dto.AvailabilityResponse +import de.deskhub.staff.data.remote.dto.AvailabilitySetRequest +import de.deskhub.staff.data.remote.dto.AvailabilitySetResponse +import de.deskhub.staff.data.remote.dto.LiveChatActionRequest +import de.deskhub.staff.data.remote.dto.LiveChatListResponse +import de.deskhub.staff.data.remote.dto.LiveChatMessagesResponse +import de.deskhub.staff.data.remote.dto.LiveChatSendRequest +import de.deskhub.staff.data.remote.dto.LiveChatSendResponse +import de.deskhub.staff.data.remote.dto.TicketRuleIdRequest +import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest +import de.deskhub.staff.data.remote.dto.TicketRuleSaveResponse +import de.deskhub.staff.data.remote.dto.TicketRulesResponse +import de.deskhub.staff.data.remote.dto.BusinessHolidayDeleteRequest +import de.deskhub.staff.data.remote.dto.BusinessHourDto +import de.deskhub.staff.data.remote.dto.BusinessHoursResponse +import de.deskhub.staff.data.remote.dto.BusinessHoursSaveRequest +import de.deskhub.staff.data.remote.dto.DashboardResponseDto +import de.deskhub.staff.data.remote.dto.DepartmentDeleteRequest +import de.deskhub.staff.data.remote.dto.DepartmentListResponse +import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest +import de.deskhub.staff.data.remote.dto.EmailAddressActionRequest +import de.deskhub.staff.data.remote.dto.EmailAddressActionResponse +import de.deskhub.staff.data.remote.dto.EmailAddressDeleteRequest +import de.deskhub.staff.data.remote.dto.EmailAddressListResponse +import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest +import de.deskhub.staff.data.remote.dto.EmailTemplateListResponse +import de.deskhub.staff.data.remote.dto.EmailTemplateResetRequest +import de.deskhub.staff.data.remote.dto.EmailTemplateSaveRequest +import de.deskhub.staff.data.remote.dto.ErrorEnvelope +import de.deskhub.staff.data.remote.dto.EscalationDeleteRequest +import de.deskhub.staff.data.remote.dto.EscalationRulesResponse +import de.deskhub.staff.data.remote.dto.EscalationSaveRequest +import de.deskhub.staff.data.remote.dto.EscalationToggleRequest +import de.deskhub.staff.data.remote.dto.HelpTopicDeleteRequest +import de.deskhub.staff.data.remote.dto.HelpTopicListResponse +import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest +import de.deskhub.staff.data.remote.dto.IdResponse +import de.deskhub.staff.data.remote.dto.KbCreateCategoryRequest +import de.deskhub.staff.data.remote.dto.KbDeleteArticleRequest +import de.deskhub.staff.data.remote.dto.KbDeleteCategoryRequest +import de.deskhub.staff.data.remote.dto.KbIdResponse +import de.deskhub.staff.data.remote.dto.KbResponse +import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest +import de.deskhub.staff.data.remote.dto.LoginRequest +import de.deskhub.staff.data.remote.dto.LoginResponse +import de.deskhub.staff.data.remote.dto.MessageRequest +import de.deskhub.staff.data.remote.dto.MessageResponse +import de.deskhub.staff.data.remote.dto.MetadataResponse +import de.deskhub.staff.data.remote.dto.OrganizationDeleteRequest +import de.deskhub.staff.data.remote.dto.OrganizationListResponse +import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest +import de.deskhub.staff.data.remote.dto.PriorityDeleteRequest +import de.deskhub.staff.data.remote.dto.PriorityListResponse +import de.deskhub.staff.data.remote.dto.PrioritySaveRequest +import de.deskhub.staff.data.remote.dto.RoleDeleteRequest +import de.deskhub.staff.data.remote.dto.RoleListResponse +import de.deskhub.staff.data.remote.dto.RoleSaveRequest +import de.deskhub.staff.data.remote.dto.StaffAdminDeleteRequest +import de.deskhub.staff.data.remote.dto.StaffAdminListResponse +import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest +import de.deskhub.staff.data.remote.dto.StaffResponse +import de.deskhub.staff.data.remote.dto.TaskCreateRequest +import de.deskhub.staff.data.remote.dto.TaskDeleteRequest +import de.deskhub.staff.data.remote.dto.TaskListResponse +import de.deskhub.staff.data.remote.dto.TaskUpdateStatusRequest +import de.deskhub.staff.data.remote.dto.TeamDeleteRequest +import de.deskhub.staff.data.remote.dto.TeamListResponse +import de.deskhub.staff.data.remote.dto.TeamSaveRequest +import de.deskhub.staff.data.remote.dto.TeamToggleRequest +import de.deskhub.staff.data.remote.dto.TicketPageResponse +import de.deskhub.staff.data.remote.dto.UploadImageResponse +import de.deskhub.staff.data.remote.dto.TicketResponse +import de.deskhub.staff.data.remote.dto.TicketSpamRequest +import de.deskhub.staff.data.remote.dto.TicketSpamResponse +import de.deskhub.staff.data.remote.dto.TicketSummaryResponse +import de.deskhub.staff.data.remote.dto.TimeEntriesResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URI +import java.util.concurrent.TimeUnit + +class ApiException( + val status: Int, + val code: String, + override val message: String, +) : Exception(message) + +object DeskHubUrl { + fun normalize(input: String): String { + val candidate = input.trim().let { + if (it.contains("://")) it else "https://$it" + } + val uri = runCatching { URI(candidate) } + .getOrElse { throw IllegalArgumentException("Die Server-Adresse ist ungültig.") } + require(uri.scheme.equals("https", ignoreCase = true)) { + "Die Server-Adresse muss HTTPS verwenden." + } + require(uri.host != null && uri.userInfo == null && uri.query == null && uri.fragment == null) { + "Die Server-Adresse ist ungültig." + } + val path = uri.path.orEmpty().trimEnd('/') + return URI("https", null, uri.host, uri.port, path, null, null).toASCIIString() + } +} + +class DeskHubApi( + serverUrl: String, + private val tokenProvider: () -> String?, +) { + private val baseUrl = DeskHubUrl.normalize(serverUrl) + private val gson = GsonBuilder().serializeNulls().create() + private val jsonType = "application/json; charset=utf-8".toMediaType() + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + suspend fun login(request: LoginRequest): LoginResponse = + execute("login.php", "POST", request, LoginResponse::class.java, authenticated = false) + + suspend fun me(): StaffResponse = + execute("me.php", "GET", null, StaffResponse::class.java) + + suspend fun metadata(): MetadataResponse = + execute("metadata.php", "GET", null, MetadataResponse::class.java) + + suspend fun tickets(query: Map): TicketPageResponse = + execute("tickets.php", "GET", null, TicketPageResponse::class.java, query = query) + + suspend fun ticket(number: String): TicketResponse = + execute( + "ticket.php", + "GET", + null, + TicketResponse::class.java, + query = mapOf("number" to number), + ) + + suspend fun updateTicket(request: Map): TicketSummaryResponse = + execute("ticket_update.php", "PATCH", request, TicketSummaryResponse::class.java) + + suspend fun addMessage(request: MessageRequest): MessageResponse = + execute("ticket_message.php", "POST", request, MessageResponse::class.java) + + suspend fun ticketSpam(number: String, action: String): TicketSpamResponse = + execute("ticket_spam.php", "POST", TicketSpamRequest(number = number, action = action), TicketSpamResponse::class.java) + + suspend fun cannedResponses(): CannedResponseListResponse = + execute("canned_responses.php", "GET", null, CannedResponseListResponse::class.java) + + suspend fun dashboard(): DashboardResponseDto = + execute("dashboard.php", "GET", null, DashboardResponseDto::class.java) + + suspend fun customers(query: String): CustomerListResponse = + execute( + "customers.php", + "GET", + null, + CustomerListResponse::class.java, + query = if (query.isBlank()) emptyMap() else mapOf("q" to query), + ) + + suspend fun customer(userId: Int?, email: String?): CustomerProfileResponse { + val query = buildMap { + if (userId != null) put("userId", userId.toString()) else email?.let { put("email", it) } + } + return execute("customer.php", "GET", null, CustomerProfileResponse::class.java, query = query) + } + + suspend fun createTicket(request: CreateTicketRequest): CreateTicketResponse = + execute("ticket_create.php", "POST", request, CreateTicketResponse::class.java) + + suspend fun activity(since: String): ActivityResponseDto = + execute("activity.php", "GET", null, ActivityResponseDto::class.java, query = mapOf("since" to since)) + + suspend fun logout() { + execute("logout.php", "POST", emptyMap(), Map::class.java) + } + + // ─── Admin: Wissensdatenbank ───────────────────────────────────────────── + + suspend fun kb(): KbResponse = + execute("kb.php", "GET", null, KbResponse::class.java) + + suspend fun kbSaveArticle(request: KbSaveArticleRequest): KbIdResponse = + execute("kb.php", "POST", request, KbIdResponse::class.java) + + suspend fun kbDeleteArticle(id: Int) { + execute("kb.php", "POST", KbDeleteArticleRequest(id = id), Map::class.java) + } + + suspend fun kbCreateCategory(name: String): KbIdResponse = + execute("kb.php", "POST", KbCreateCategoryRequest(name = name), KbIdResponse::class.java) + + suspend fun kbDeleteCategory(id: Int) { + execute("kb.php", "POST", KbDeleteCategoryRequest(id = id), Map::class.java) + } + + // ─── Admin: Zeiterfassung ──────────────────────────────────────────────── + + suspend fun timeEntries(query: Map): TimeEntriesResponse = + execute("time_entries.php", "GET", null, TimeEntriesResponse::class.java, query = query) + + // ─── Admin: Eskalationsregeln ──────────────────────────────────────────── + + suspend fun escalationRules(): EscalationRulesResponse = + execute("escalation_rules.php", "GET", null, EscalationRulesResponse::class.java) + + suspend fun escalationSave(request: EscalationSaveRequest): KbIdResponse = + execute("escalation_rules.php", "POST", request, KbIdResponse::class.java) + + suspend fun escalationDelete(id: Int) { + execute("escalation_rules.php", "POST", EscalationDeleteRequest(id = id), Map::class.java) + } + + suspend fun escalationToggle(id: Int) { + execute("escalation_rules.php", "POST", EscalationToggleRequest(id = id), Map::class.java) + } + + // ─── Admin: Sperrliste ─────────────────────────────────────────────────── + + suspend fun banlist(): BanlistResponse = + execute("banlist.php", "GET", null, BanlistResponse::class.java) + + suspend fun banCreate(matchType: String, value: String, reason: String?) { + execute("banlist.php", "POST", BanCreateRequest(matchType = matchType, value = value, reason = reason), Map::class.java) + } + + suspend fun banBulkAdd(values: String, reason: String?): BanBulkAddResponse = + execute("banlist.php", "POST", BanBulkAddRequest(values = values, reason = reason), BanBulkAddResponse::class.java) + + suspend fun banDelete(id: Int) { + execute("banlist.php", "POST", BanDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Abteilungen ────────────────────────────────────────────────── + + suspend fun departments(): DepartmentListResponse = + execute("departments.php", "GET", null, DepartmentListResponse::class.java) + + suspend fun departmentSave(request: DepartmentSaveRequest): IdResponse = + execute("departments.php", "POST", request, IdResponse::class.java) + + suspend fun departmentDelete(id: Int) { + execute("departments.php", "POST", DepartmentDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Prioritäten ────────────────────────────────────────────────── + + suspend fun report(query: Map): de.deskhub.staff.data.remote.dto.ReportResponseDto = + execute("report_builder.php", "GET", null, de.deskhub.staff.data.remote.dto.ReportResponseDto::class.java, query = query) + + suspend fun reportSave(request: de.deskhub.staff.data.remote.dto.ReportSaveRequest): IdResponse = + execute("report_builder.php", "POST", request, IdResponse::class.java) + + suspend fun reportDelete(id: Int) { + execute("report_builder.php", "POST", de.deskhub.staff.data.remote.dto.ReportDeleteRequest(id = id), Map::class.java) + } + + suspend fun savedViews(): de.deskhub.staff.data.remote.dto.SavedViewListResponse = + execute("queues.php", "GET", null, de.deskhub.staff.data.remote.dto.SavedViewListResponse::class.java) + + suspend fun savedViewSave(request: de.deskhub.staff.data.remote.dto.SavedViewSaveRequest): IdResponse = + execute("queues.php", "POST", request, IdResponse::class.java) + + suspend fun savedViewDelete(id: Int) { + execute("queues.php", "POST", de.deskhub.staff.data.remote.dto.SavedViewDeleteRequest(id = id), Map::class.java) + } + + suspend fun slaPlans(): de.deskhub.staff.data.remote.dto.SlaPlanListResponse = + execute("sla_plans.php", "GET", null, de.deskhub.staff.data.remote.dto.SlaPlanListResponse::class.java) + + suspend fun slaPlanSave(request: de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest): IdResponse = + execute("sla_plans.php", "POST", request, IdResponse::class.java) + + suspend fun slaPlanDelete(id: Int) { + execute("sla_plans.php", "POST", de.deskhub.staff.data.remote.dto.SlaPlanDeleteRequest(id = id), Map::class.java) + } + + suspend fun prioritiesAdmin(): PriorityListResponse = + execute("priorities.php", "GET", null, PriorityListResponse::class.java) + + suspend fun prioritySave(request: PrioritySaveRequest): IdResponse = + execute("priorities.php", "POST", request, IdResponse::class.java) + + suspend fun priorityDelete(id: Int) { + execute("priorities.php", "POST", PriorityDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Hilfe-Themen ───────────────────────────────────────────────── + + suspend fun helpTopics(): HelpTopicListResponse = + execute("help_topics.php", "GET", null, HelpTopicListResponse::class.java) + + suspend fun helpTopicSave(request: HelpTopicSaveRequest): IdResponse = + execute("help_topics.php", "POST", request, IdResponse::class.java) + + suspend fun helpTopicDelete(id: Int) { + execute("help_topics.php", "POST", HelpTopicDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Organisationen ─────────────────────────────────────────────── + + suspend fun organizations(): OrganizationListResponse = + execute("organizations.php", "GET", null, OrganizationListResponse::class.java) + + suspend fun organizationSave(request: OrganizationSaveRequest): IdResponse = + execute("organizations.php", "POST", request, IdResponse::class.java) + + suspend fun organizationDelete(id: Int) { + execute("organizations.php", "POST", OrganizationDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Teams ──────────────────────────────────────────────────────── + + suspend fun teams(): TeamListResponse = + execute("teams.php", "GET", null, TeamListResponse::class.java) + + suspend fun teamSave(request: TeamSaveRequest): IdResponse = + execute("teams.php", "POST", request, IdResponse::class.java) + + suspend fun teamToggle(id: Int) { + execute("teams.php", "POST", TeamToggleRequest(id = id), Map::class.java) + } + + suspend fun teamDelete(id: Int) { + execute("teams.php", "POST", TeamDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Rollen ─────────────────────────────────────────────────────── + + suspend fun roles(): RoleListResponse = + execute("roles.php", "GET", null, RoleListResponse::class.java) + + suspend fun roleSave(request: RoleSaveRequest): IdResponse = + execute("roles.php", "POST", request, IdResponse::class.java) + + suspend fun roleDelete(id: Int) { + execute("roles.php", "POST", RoleDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Mitarbeiter ────────────────────────────────────────────────── + + suspend fun staffAdminList(): StaffAdminListResponse = + execute("staff_manage.php", "GET", null, StaffAdminListResponse::class.java) + + suspend fun staffAdminSave(request: StaffAdminSaveRequest): IdResponse = + execute("staff_manage.php", "POST", request, IdResponse::class.java) + + suspend fun staffAdminDelete(id: Int) { + execute("staff_manage.php", "POST", StaffAdminDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Geschäftszeiten ────────────────────────────────────────────── + + suspend fun businessHours(): BusinessHoursResponse = + execute("business_hours.php", "GET", null, BusinessHoursResponse::class.java) + + suspend fun businessHoursSave(enabled: Boolean, hours: List) { + execute("business_hours.php", "POST", BusinessHoursSaveRequest(enabled = enabled, hours = hours), Map::class.java) + } + + suspend fun businessHolidayAdd(date: String, name: String): IdResponse = + execute("business_hours.php", "POST", BusinessHolidayAddRequest(date = date, name = name), IdResponse::class.java) + + suspend fun businessHolidayDelete(id: Int) { + execute("business_hours.php", "POST", BusinessHolidayDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: E-Mail-Adressen ────────────────────────────────────────────── + + suspend fun emailAddresses(): EmailAddressListResponse = + execute("email_addresses.php", "GET", null, EmailAddressListResponse::class.java) + + suspend fun emailAddressSave(request: EmailAddressSaveRequest): IdResponse = + execute("email_addresses.php", "POST", request, IdResponse::class.java) + + suspend fun emailAddressAction(action: String, id: Int): EmailAddressActionResponse = + execute("email_addresses.php", "POST", EmailAddressActionRequest(action = action, id = id), EmailAddressActionResponse::class.java) + + suspend fun emailAddressDelete(id: Int) { + execute("email_addresses.php", "POST", EmailAddressDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: E-Mail-Vorlagen ────────────────────────────────────────────── + + suspend fun emailTemplates(): EmailTemplateListResponse = + execute("email_templates.php", "GET", null, EmailTemplateListResponse::class.java) + + suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) { + execute("email_templates.php", "POST", EmailTemplateSaveRequest(key = key, subject = subject, body = bodyText), Map::class.java) + } + + suspend fun emailTemplateReset(key: String) { + execute("email_templates.php", "POST", EmailTemplateResetRequest(key = key), Map::class.java) + } + + // ─── Admin: Admin-Protokoll ────────────────────────────────────────────── + + suspend fun auditLog(page: Int): AuditLogResponse = + execute("audit_log.php", "GET", null, AuditLogResponse::class.java, query = mapOf("page" to page.toString())) + + // ─── Aufgaben ──────────────────────────────────────────────────────────── + + suspend fun tasks(filter: String): TaskListResponse = + execute("tasks.php", "GET", null, TaskListResponse::class.java, query = mapOf("filter" to filter)) + + suspend fun taskCreate(request: TaskCreateRequest): IdResponse = + execute("tasks.php", "POST", request, IdResponse::class.java) + + suspend fun taskUpdateStatus(id: Int, status: String) { + execute("tasks.php", "POST", TaskUpdateStatusRequest(id = id, status = status), Map::class.java) + } + + suspend fun taskDelete(id: Int) { + execute("tasks.php", "POST", TaskDeleteRequest(id = id), Map::class.java) + } + + // ─── Interner Chat ─────────────────────────────────────────────────────── + + suspend fun chatContacts(): ChatContactsResponse = + execute("chat.php", "GET", null, ChatContactsResponse::class.java) + + suspend fun chatConversation(withId: Int): ChatConversationResponse = + execute("chat.php", "GET", null, ChatConversationResponse::class.java, query = mapOf("with" to withId.toString())) + + suspend fun chatSend(toId: Int, message: String): ChatSendResponse = + execute("chat.php", "POST", ChatSendRequest(toId = toId, message = message), ChatSendResponse::class.java) + + // ─── Live-Chat (Kunden-Widget) ─────────────────────────────────────────── + + suspend fun liveChatList(): LiveChatListResponse = + execute("live_chat.php", "GET", null, LiveChatListResponse::class.java, query = mapOf("action" to "list")) + + suspend fun liveChatMessages(number: String, since: Int): LiveChatMessagesResponse = + execute( + "live_chat.php", "GET", null, LiveChatMessagesResponse::class.java, + query = mapOf("action" to "messages", "number" to number, "since" to since.toString()), + ) + + suspend fun liveChatSend(number: String, message: String): LiveChatSendResponse = + execute("live_chat.php", "POST", LiveChatSendRequest(number = number, message = message), LiveChatSendResponse::class.java) + + suspend fun liveChatTyping(number: String) { + execute("live_chat.php", "POST", LiveChatActionRequest(action = "typing", number = number), Map::class.java) + } + + suspend fun liveChatEnd(number: String) { + execute("live_chat.php", "POST", LiveChatActionRequest(action = "end", number = number), Map::class.java) + } + + // ─── Verfügbarkeit (Status) ────────────────────────────────────────────── + + suspend fun availability(): AvailabilityResponse = + execute("availability.php", "GET", null, AvailabilityResponse::class.java) + + suspend fun setAvailability(status: String, note: String): AvailabilitySetResponse = + execute("availability.php", "POST", AvailabilitySetRequest(status, note), AvailabilitySetResponse::class.java) + + // ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────── + + suspend fun ticketRules(): TicketRulesResponse = + execute("ticket_rules.php", "GET", null, TicketRulesResponse::class.java) + + suspend fun saveTicketRule(request: TicketRuleSaveRequest): TicketRuleSaveResponse = + execute("ticket_rules.php", "POST", request, TicketRuleSaveResponse::class.java) + + suspend fun toggleTicketRule(id: Int) { + execute("ticket_rules.php", "POST", TicketRuleIdRequest("toggle", id), Map::class.java) + } + + suspend fun deleteTicketRule(id: Int) { + execute("ticket_rules.php", "POST", TicketRuleIdRequest("delete", id), Map::class.java) + } + + suspend fun saveAutoClose(enabled: Boolean, days: Int) { + execute("ticket_rules.php", "POST", AutoCloseSaveRequest(enabled = enabled, days = days), Map::class.java) + } + + private suspend fun execute( + path: String, + method: String, + payload: Any?, + responseType: Class, + authenticated: Boolean = true, + query: Map = emptyMap(), + ): T = withContext(Dispatchers.IO) { + val urlBuilder = "$baseUrl/api/staff/v1/$path".toHttpUrl().newBuilder() + query.forEach { (key, value) -> urlBuilder.addQueryParameter(key, value) } + val requestBuilder = Request.Builder() + .url(urlBuilder.build()) + .header("Accept", "application/json") + .header("Cache-Control", "no-cache, no-store") + .header("Pragma", "no-cache") + if (authenticated) { + val token = tokenProvider() + ?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.") + requestBuilder.header("Authorization", "Bearer $token") + } + val body = payload?.let { gson.toJson(it).toRequestBody(jsonType) } + when (method) { + "GET" -> requestBuilder.get() + "POST" -> requestBuilder.post(body ?: ByteArray(0).toRequestBody(jsonType)) + "PATCH" -> requestBuilder.patch(body ?: ByteArray(0).toRequestBody(jsonType)) + else -> error("Nicht unterstützte HTTP-Methode: $method") + } + + val call = try { + client.newCall(requestBuilder.build()).execute() + } catch (e: java.io.IOException) { + // Kein Netz, Server nicht erreichbar oder Zeitüberschreitung + ConnectionMonitor.set(true) + throw ApiException(0, "NETWORK", "Keine Verbindung zum Server.") + } + ConnectionMonitor.set(call.code in 502..504) + call.use { response -> + val responseBody = response.body?.string().orEmpty() + if (!response.isSuccessful) { + val envelope = runCatching { gson.fromJson(responseBody, ErrorEnvelope::class.java) }.getOrNull() + throw ApiException( + response.code, + envelope?.error?.code ?: "HTTP_${response.code}", + envelope?.error?.message ?: "Serverfehler (${response.code}).", + ) + } + if (responseBody.isBlank()) { + throw ApiException(response.code, "EMPTY_RESPONSE", "Der Server hat keine Antwort geliefert.") + } + gson.fromJson(responseBody, responseType) + ?: throw ApiException(response.code, "INVALID_RESPONSE", "Die Serverantwort ist ungültig.") + } + } + + /** Lädt ein Bild hoch (z.B. für eine Signatur) und liefert dessen öffentliche URL zurück. */ + suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): UploadImageResponse = + withContext(Dispatchers.IO) { + val token = tokenProvider() + ?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.") + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("image", filename, bytes.toRequestBody(mimeType.toMediaType())) + .build() + val request = Request.Builder() + .url("$baseUrl/api/staff/v1/upload_image.php".toHttpUrl()) + .header("Accept", "application/json") + .header("Authorization", "Bearer $token") + .post(body) + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + if (!response.isSuccessful) { + val envelope = runCatching { gson.fromJson(responseBody, ErrorEnvelope::class.java) }.getOrNull() + throw ApiException( + response.code, + envelope?.error?.code ?: "HTTP_${response.code}", + envelope?.error?.message ?: "Serverfehler (${response.code}).", + ) + } + gson.fromJson(responseBody, UploadImageResponse::class.java) + ?: throw ApiException(response.code, "INVALID_RESPONSE", "Die Serverantwort ist ungültig.") + } + } +} diff --git a/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt b/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt new file mode 100644 index 00000000..5a2a813d --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt @@ -0,0 +1,1046 @@ +package de.deskhub.staff.data.remote.dto + +data class StaffDto( + val id: Int = 0, + val name: String = "", + val email: String = "", + val role: String = "", + val permissions: Map = emptyMap(), +) + +data class NamedOptionDto( + val id: Int = 0, + val name: String = "", +) + +data class StatusOptionDto( + val value: String = "", + val label: String = "", + val color: String = "", +) + +data class PriorityDto( + val id: Int = 0, + val name: String = "", + val color: String = "", +) + +data class TicketSummaryDto( + val id: Int = 0, + val number: String = "", + val subject: String = "", + val requesterName: String = "", + val requesterEmail: String = "", + val status: String = "", + val priority: PriorityDto = PriorityDto(), + val department: NamedOptionDto = NamedOptionDto(), + val assignedStaff: NamedOptionDto? = null, + val isOverdue: Boolean = false, + val dueLabel: String? = null, + val isSpam: Boolean = false, + val createdAt: String = "", + val updatedAt: String = "", +) + +data class AttachmentDto( + val id: Int = 0, + val name: String = "", + val size: Long = 0, +) + +data class ThreadCommentDto( + val id: Int = 0, + val message: String = "", + val staff: NamedOptionDto = NamedOptionDto(), + val createdAt: String = "", +) + +data class TicketThreadDto( + val id: Int = 0, + val authorType: String = "", + val authorName: String = "", + val message: String = "", + val isInternalNote: Boolean = false, + val createdAt: String = "", + val attachments: List = emptyList(), + val comments: List = emptyList(), +) + +data class TicketDetailDto( + val id: Int = 0, + val number: String = "", + val subject: String = "", + val requesterName: String = "", + val requesterEmail: String = "", + val status: String = "", + val priority: PriorityDto = PriorityDto(), + val department: NamedOptionDto = NamedOptionDto(), + val assignedStaff: NamedOptionDto? = null, + val isOverdue: Boolean = false, + val isSpam: Boolean = false, + val createdAt: String = "", + val updatedAt: String = "", + val source: String = "", + val firstResponseDueAt: String? = null, + val resolutionDueAt: String? = null, + val lockedByOtherName: String? = null, + val threads: List = emptyList(), + val permissions: Map? = null, +) + +data class TicketSpamRequest( + val number: String, + val action: String, +) + +data class TicketSpamResponse( + val ticket: TicketSummaryDto = TicketSummaryDto(), + val moved: Int = 0, + val failed: List = emptyList(), +) + +data class LoginRequest( + val email: String, + val password: String, + val totpCode: String? = null, + val deviceName: String, +) + +data class LoginResponse( + val token: String? = null, + val expiresAt: String? = null, + val staff: StaffDto? = null, + val requiresTotp: Boolean = false, +) + +data class StaffResponse(val staff: StaffDto = StaffDto()) + +data class MetadataResponse( + val staff: StaffDto = StaffDto(), + val departments: List = emptyList(), + val priorities: List = emptyList(), + val agents: List = emptyList(), + val statuses: List = emptyList(), + val tags: List = emptyList(), + val liveChatEnabled: Boolean = false, + val health: List = emptyList(), +) + +data class TicketPageResponse( + val items: List = emptyList(), + val page: Int = 1, + val perPage: Int = 30, + val total: Int = 0, + val hasMore: Boolean = false, +) + +data class TicketResponse(val ticket: TicketDetailDto = TicketDetailDto()) + +data class TicketSummaryResponse(val ticket: TicketSummaryDto = TicketSummaryDto()) + +data class UpdateTicketRequest( + val number: String, + val status: String? = null, + val assignedStaffId: Int? = null, +) + +data class MessageRequest( + val number: String, + val type: String, + val message: String, + val setStatus: String? = null, +) + +data class MessageResponse(val threadId: Int = 0) + +data class CannedResponseDto( + val id: Int = 0, + val title: String = "", + val body: String = "", + val departmentId: Int? = null, +) + +data class CannedResponseListResponse(val items: List = emptyList()) + +data class DepartmentStatDto( + val name: String = "", + val open: Int = 0, + val overdue: Int = 0, +) + +data class DashboardResponseDto( + val myOpen: Int = 0, + val overdue: Int = 0, + val unassigned: Int = 0, + val unanswered: Int = 0, + val resolvedToday: Int = 0, + val slaPercent: Double? = null, + val recent: List = emptyList(), + val departments: List? = null, +) + +data class CustomerDto( + val id: Int? = null, + val name: String = "", + val email: String = "", + val organizationName: String? = null, + val ticketCount: Int = 0, + val isGuest: Boolean = false, +) + +data class CustomerListResponse(val items: List = emptyList()) + +data class CustomerDetailDto( + val id: Int? = null, + val name: String = "", + val email: String = "", + val organizationName: String? = null, + val isGuest: Boolean = false, + val createdAt: String = "", +) + +data class CustomerProfileResponse( + val customer: CustomerDetailDto = CustomerDetailDto(), + val tickets: List = emptyList(), +) + +data class CreateTicketRequest( + val requesterName: String, + val requesterEmail: String, + val subject: String, + val message: String, + val departmentId: Int, + val priorityId: Int, + val assignedStaffId: Int? = null, + val notifyCustomer: Boolean = true, +) + +data class CreateTicketResponse(val ticket: TicketSummaryDto = TicketSummaryDto()) + +data class HealthIssueDto( + val level: String = "warning", + val code: String = "", + val message: String = "", +) + +data class ActivityResponseDto( + val newReplies: Int = 0, + val newUnassigned: Int = 0, + val serverTime: String = "", + val health: List = emptyList(), +) + +data class ErrorEnvelope(val error: ApiErrorDto = ApiErrorDto()) + +data class ApiErrorDto( + val code: String = "UNKNOWN", + val message: String = "Unbekannter Serverfehler.", +) + +// ─── Admin: Wissensdatenbank ─────────────────────────────────────────────── + +data class KbCategoryDto( + val id: Int = 0, + val name: String = "", +) + +data class KbArticleDto( + val id: Int = 0, + val categoryId: Int = 0, + val categoryName: String = "", + val title: String = "", + val body: String = "", + val isPublished: Boolean = false, + val views: Int = 0, +) + +data class KbResponse( + val categories: List = emptyList(), + val articles: List = emptyList(), +) + +data class KbIdResponse(val id: Int = 0) + +data class KbSaveArticleRequest( + val action: String = "save_article", + val id: Int? = null, + val title: String, + val categoryId: Int, + val body: String, + val isPublished: Boolean, +) + +data class KbDeleteArticleRequest(val action: String = "delete_article", val id: Int) +data class KbCreateCategoryRequest(val action: String = "create_category", val name: String) +data class KbDeleteCategoryRequest(val action: String = "delete_category", val id: Int) + +// ─── Admin: Zeiterfassung ─────────────────────────────────────────────────── + +data class TimeEntryDto( + val id: Int = 0, + val staffName: String = "", + val ticketNumber: String = "", + val subject: String = "", + val minutes: Int = 0, + val note: String? = null, + val loggedAt: String = "", +) + +data class TimeEntryTotalDto( + val staffName: String = "", + val minutes: Int = 0, +) + +data class TimeEntriesResponse( + val staffOptions: List = emptyList(), + val entries: List = emptyList(), + val totalsByStaff: List = emptyList(), + val totalMinutes: Int = 0, +) + +// ─── Admin: Eskalationsregeln ─────────────────────────────────────────────── + +data class EscalationRuleDto( + val id: Int = 0, + val name: String = "", + val isActive: Boolean = true, + val triggerType: String = "no_reply", + val triggerHours: Int = 24, + val conditionStatus: String? = null, + val conditionPriorityId: Int? = null, + val conditionPriorityName: String? = null, + val conditionDeptId: Int? = null, + val conditionDeptName: String? = null, + val actionSetPriorityId: Int? = null, + val actionSetPriorityName: String? = null, + val actionAssignStaffId: Int? = null, + val actionAssignStaffName: String? = null, + val actionSetStatus: String? = null, + val actionNotifyAdmin: Boolean = true, + val actionNotifyStaff: Boolean = false, + val sortOrder: Int = 0, + val appliedCount: Int = 0, +) + +data class EscalationRulesResponse( + val priorities: List = emptyList(), + val staffOptions: List = emptyList(), + val departments: List = emptyList(), + val rules: List = emptyList(), +) + +data class EscalationSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val isActive: Boolean, + val triggerType: String, + val triggerHours: Int, + val conditionStatus: String? = null, + val conditionPriorityId: Int? = null, + val conditionDeptId: Int? = null, + val actionSetPriorityId: Int? = null, + val actionAssignStaffId: Int? = null, + val actionSetStatus: String? = null, + val actionNotifyAdmin: Boolean, + val actionNotifyStaff: Boolean, + val sortOrder: Int = 0, +) + +data class EscalationDeleteRequest(val action: String = "delete", val id: Int) +data class EscalationToggleRequest(val action: String = "toggle", val id: Int) + +// ─── Admin: Sperrliste ─────────────────────────────────────────────────────── + +data class BanEntryDto( + val id: Int = 0, + val matchType: String = "email", + val value: String = "", + val reason: String? = null, + val addedByName: String? = null, + val createdAt: String = "", +) + +data class BanlistResponse(val items: List = emptyList()) + +data class BanCreateRequest( + val action: String = "create", + val matchType: String, + val value: String, + val reason: String? = null, +) + +data class BanBulkAddRequest( + val action: String = "bulk_add", + val values: String, + val reason: String? = null, +) + +data class BanBulkAddResponse(val added: Int = 0, val skipped: Int = 0) + +data class BanDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: generische kleine Antworten ───────────────────────────────────── + +data class IdResponse(val id: Int = 0) + +// ─── Admin: Abteilungen ────────────────────────────────────────────────────── + +data class DepartmentMemberDto( + val id: Int = 0, + val name: String = "", + val isPrimary: Boolean = true, +) + +data class DepartmentDto( + val id: Int = 0, + val name: String = "", + val email: String = "", + val isDefault: Boolean = false, + val signature: String? = null, + val isActive: Boolean = true, + val managerId: Int? = null, + val managerName: String? = null, + val slaPlanId: Int? = null, + val notes: String? = null, + val members: List = emptyList(), + val createdAt: String? = null, + val updatedAt: String? = null, +) + +data class DepartmentEmailOptionDto(val email: String = "", val departmentName: String = "") + +data class DepartmentListResponse( + val items: List = emptyList(), + val emailOptions: List = emptyList(), + val staffOptions: List = emptyList(), + val slaPlans: List = emptyList(), +) + +data class DepartmentSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val email: String, + val isDefault: Boolean, + val signature: String? = null, + val isActive: Boolean = true, + val managerId: Int? = null, + val slaPlanId: Int? = null, + val notes: String = "", +) + +data class DepartmentDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Berichte (Bericht-Baukasten) ───────────────────────────────────────────── + +data class ReportColumnDto(val key: String = "", val label: String = "", val unit: String = "") + +data class ReportOptionDto(val key: String = "", val label: String = "", val unit: String = "") + +data class ReportSavedDto( + val id: Int = 0, + val name: String = "", + val isShared: Boolean = false, + val mine: Boolean = true, + val ownerName: String? = null, +) + +data class ReportConfigDto( + val dimension: String = "department", + val metrics: List = listOf("total", "open", "resolved"), + val from: String = "", + val to: String = "", + val sort: String = "label", + val desc: Boolean = false, +) + +data class ReportResponseDto( + val config: ReportConfigDto = ReportConfigDto(), + val columns: List = emptyList(), + val rows: List> = emptyList(), + val totals: Map = emptyMap(), + val dimensions: List = emptyList(), + val metrics: List = emptyList(), + val saved: List = emptyList(), +) + +data class ReportSaveRequest( + val action: String = "save", + val name: String, + val isShared: Boolean, + val config: ReportConfigDto, +) + +data class ReportDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Gespeicherte Ansichten ─────────────────────────────────────────────────── + +data class SavedViewDto( + val id: Int = 0, + val name: String = "", + val isShared: Boolean = false, + val mine: Boolean = true, + val ownerName: String? = null, + val filters: Map = emptyMap(), +) + +data class SavedViewListResponse(val items: List = emptyList()) + +data class SavedViewSaveRequest( + val action: String = "save", + val name: String, + val isShared: Boolean, + val filters: Map, +) + +data class SavedViewDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: SLA-Pläne ──────────────────────────────────────────────────────── + +data class SlaPlanDto( + val id: Int = 0, + val name: String = "", + val responseHours: Int = 24, + val resolutionHours: Int = 72, + val isActive: Boolean = true, + val notes: String? = null, + val departmentCount: Int = 0, + val helpTopicCount: Int = 0, + val updatedAt: String? = null, +) + +data class SlaPlanListResponse(val items: List = emptyList()) + +data class SlaPlanSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val responseHours: Int, + val resolutionHours: Int, + val isActive: Boolean = true, + val notes: String = "", +) + +data class SlaPlanDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Prioritäten ────────────────────────────────────────────────────── + +data class PriorityFullDto( + val id: Int = 0, + val name: String = "", + val color: String = "", + val responseHours: Int = 0, + val resolutionHours: Int = 0, + val sortOrder: Int = 0, +) + +data class PriorityListResponse(val items: List = emptyList()) + +data class PrioritySaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val color: String, + val responseHours: Int, + val resolutionHours: Int, + val sortOrder: Int = 0, +) + +data class PriorityDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Hilfe-Themen ───────────────────────────────────────────────────── + +data class HelpTopicDto( + val id: Int = 0, + val name: String = "", + val departmentId: Int = 0, + val departmentName: String = "", + val priorityId: Int = 0, + val priorityName: String = "", + val priorityColor: String = "", + val isActive: Boolean = true, + val isPublic: Boolean = true, + val parentId: Int? = null, + val slaPlanId: Int? = null, + val path: String = "", + val notes: String? = null, + val sortOrder: Int = 0, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +data class HelpTopicListResponse( + val departments: List = emptyList(), + val priorities: List = emptyList(), + val slaPlans: List = emptyList(), + val items: List = emptyList(), +) + +data class HelpTopicSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val departmentId: Int, + val priorityId: Int, + val isActive: Boolean, + val isPublic: Boolean = true, + val parentId: Int? = null, + val slaPlanId: Int? = null, + val notes: String = "", + val sortOrder: Int = 0, +) + +data class HelpTopicDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Organisationen ─────────────────────────────────────────────────── + +data class OrganizationDto( + val id: Int = 0, + val name: String = "", + val notes: String? = null, + val memberCount: Int = 0, +) + +data class OrganizationListResponse(val items: List = emptyList()) + +data class OrganizationSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val notes: String? = null, +) + +data class OrganizationDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Teams ──────────────────────────────────────────────────────────── + +data class TeamDto( + val id: Int = 0, + val name: String = "", + val leadId: Int? = null, + val leadName: String? = null, + val notes: String? = null, + val isActive: Boolean = true, + val memberCount: Int = 0, + val memberIds: List = emptyList(), +) + +data class TeamListResponse( + val staffOptions: List = emptyList(), + val items: List = emptyList(), +) + +data class TeamSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val leadId: Int? = null, + val notes: String? = null, + val memberIds: List, +) + +data class TeamDeleteRequest(val action: String = "delete", val id: Int) +data class TeamToggleRequest(val action: String = "toggle", val id: Int) + +// ─── Admin: Rollen ─────────────────────────────────────────────────────────── + +data class RoleDto( + val id: Int = 0, + val name: String = "", + val permissions: Map = emptyMap(), + val staffCount: Int = 0, +) + +data class RoleListResponse( + val permissionKeys: List = emptyList(), + val defaultAgentPermissions: Map = emptyMap(), + val items: List = emptyList(), +) + +data class RoleSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val permissions: Map, +) + +data class RoleDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Mitarbeiter ────────────────────────────────────────────────────── + +data class StaffExtraDeptDto( + val departmentId: Int = 0, + val notify: Boolean = false, + val roleId: Int? = null, +) + +data class StaffAdminDto( + val id: Int = 0, + val name: String = "", + val email: String = "", + val role: String = "", + val roleId: Int? = null, + val departmentId: Int? = null, + val departmentName: String? = null, + val isActive: Boolean = true, + val signature: String? = null, + val extraDepartments: List = emptyList(), + val teamIds: List = emptyList(), + val createdAt: String? = null, +) + +data class StaffAdminListResponse( + val departments: List = emptyList(), + val roles: List = emptyList(), + val teams: List = emptyList(), + val canManageTeams: Boolean = false, + val items: List = emptyList(), +) + +data class StaffAdminSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val email: String, + val role: String, + val roleId: Int? = null, + val departmentId: Int? = null, + val isActive: Boolean, + val password: String = "", + val signature: String? = null, + val extraDepartments: List? = null, + val teamIds: List? = null, +) + +data class StaffAdminDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Geschäftszeiten ────────────────────────────────────────────────── + +data class BusinessHourDto( + val weekday: Int = 0, + val isOpen: Boolean = false, + val openTime: String = "09:00", + val closeTime: String = "17:00", +) + +data class BusinessHolidayDto( + val id: Int = 0, + val date: String = "", + val name: String = "", +) + +data class BusinessHoursResponse( + val enabled: Boolean = false, + val hours: List = emptyList(), + val holidays: List = emptyList(), +) + +data class BusinessHoursSaveRequest( + val action: String = "save_hours", + val enabled: Boolean, + val hours: List, +) + +data class BusinessHolidayAddRequest( + val action: String = "add_holiday", + val date: String, + val name: String, +) + +data class BusinessHolidayDeleteRequest(val action: String = "delete_holiday", val id: Int) + +// ─── Admin: E-Mail-Adressen ────────────────────────────────────────────────── + +data class EmailAddressDto( + val id: Int = 0, + val email: String = "", + val label: String? = null, + val departmentId: Int = 0, + val departmentName: String = "", + val priorityId: Int? = null, + val priorityName: String? = null, + val helpTopicId: Int? = null, + val helpTopicName: String? = null, + val isDefault: Boolean = false, + val isActive: Boolean = true, + val notes: String? = null, + val updatedAt: String? = null, + val imapEnabled: Boolean = false, + val imapHost: String = "", + val imapPort: Int = 993, + val imapEncryption: String = "ssl", + val imapUsername: String = "", + val imapFolder: String = "INBOX", + val imapAfterFetch: String = "seen", + val imapArchiveFolder: String = "Processed", + val imapSpamFolder: String = "Junk", + val imapHasPassword: Boolean = false, + val imapLastRunAt: String? = null, + val smtpEnabled: Boolean = false, + val smtpHost: String = "", + val smtpPort: Int = 587, + val smtpEncryption: String = "tls", + val smtpUsername: String = "", + val smtpHasPassword: Boolean = false, +) + +data class EmailAddressListResponse( + val departments: List = emptyList(), + val priorities: List = emptyList(), + val helpTopics: List = emptyList(), + val items: List = emptyList(), +) + +data class EmailAddressSaveRequest( + val action: String = "save", + val id: Int? = null, + val email: String, + val label: String? = null, + val departmentId: Int, + val priorityId: Int? = null, + val helpTopicId: Int? = null, + val isDefault: Boolean, + val isActive: Boolean = true, + val notes: String = "", + val imapEnabled: Boolean = false, + val imapHost: String = "", + val imapPort: Int = 993, + val imapEncryption: String = "ssl", + val imapUsername: String = "", + val imapPassword: String = "", + val imapFolder: String = "INBOX", + val imapAfterFetch: String = "seen", + val imapArchiveFolder: String = "Processed", + val imapSpamFolder: String = "Junk", + val smtpEnabled: Boolean = false, + val smtpHost: String = "", + val smtpPort: Int = 587, + val smtpEncryption: String = "tls", + val smtpUsername: String = "", + val smtpPassword: String = "", +) + +data class EmailAddressActionRequest(val action: String, val id: Int) + +data class EmailAddressActionResponse(val ok: Boolean = true, val message: String = "") + +data class EmailAddressDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: E-Mail-Vorlagen ────────────────────────────────────────────────── + +data class EmailTemplateDto( + val key: String = "", + val label: String = "", + val placeholders: List = emptyList(), + val subject: String = "", + val body: String = "", + val isCustomized: Boolean = false, +) + +data class EmailTemplateListResponse(val items: List = emptyList()) + +data class EmailTemplateSaveRequest( + val action: String = "save", + val key: String, + val subject: String, + val body: String, +) + +data class EmailTemplateResetRequest(val action: String = "reset", val key: String) + +// ─── Admin: Admin-Protokoll ────────────────────────────────────────────────── + +data class AuditLogEntryDto( + val id: Int = 0, + val staffName: String? = null, + val action: String = "", + val createdAt: String = "", +) + +data class AuditLogResponse( + val items: List = emptyList(), + val page: Int = 1, + val perPage: Int = 50, + val total: Int = 0, + val hasMore: Boolean = false, +) + +// ─── Aufgaben (alle Mitarbeiter) ───────────────────────────────────────────── + +data class TaskDto( + val id: Int = 0, + val title: String = "", + val description: String? = null, + val status: String = "open", + val assignedToId: Int? = null, + val assignedName: String? = null, + val creatorName: String? = null, + val dueAt: String? = null, + val ticketNumber: String? = null, + val ticketSubject: String? = null, +) + +data class TaskListResponse( + val staffOptions: List = emptyList(), + val items: List = emptyList(), +) + +data class TaskCreateRequest( + val action: String = "create", + val title: String, + val description: String? = null, + val assignedToId: Int? = null, + val dueAt: String? = null, + val ticketNumber: String? = null, +) + +data class TaskUpdateStatusRequest(val action: String = "update_status", val id: Int, val status: String) +data class TaskDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Interner Chat (alle Mitarbeiter) ──────────────────────────────────────── + +data class ChatContactDto( + val id: Int = 0, + val name: String = "", + val unreadCount: Int = 0, + val lastMessageAt: String? = null, +) + +data class ChatContactsResponse(val contacts: List = emptyList()) + +data class ChatMessageDto( + val id: Int = 0, + val fromStaffId: Int = 0, + val fromName: String = "", + val message: String = "", + val createdAt: String = "", +) + +data class ChatConversationResponse( + val with: NamedOptionDto = NamedOptionDto(), + val messages: List = emptyList(), +) + +data class ChatSendRequest(val action: String = "send", val toId: Int, val message: String) +data class ChatSendResponse(val id: Int = 0) + +// ─── Live-Chat (Kunden-Widget, source='chat') ──────────────────────────────── + +data class LiveChatListItemDto( + val number: String = "", + val name: String = "", + val preview: String = "", + val needsReply: Boolean = false, + val updatedAt: String = "", +) + +data class LiveChatListResponse(val items: List = emptyList()) + +data class LiveChatAttachmentDto( + val id: Int = 0, + val name: String = "", + val size: Long = 0, +) + +data class LiveChatMessageDto( + val id: Int = 0, + val authorType: String = "", + val authorName: String? = null, + val message: String = "", + val createdAt: String = "", + val attachments: List = emptyList(), +) + +data class LiveChatMessagesResponse( + val status: String = "", + val requesterName: String = "", + val requesterEmail: String = "", + val departmentName: String = "", + val lockedByOtherName: String? = null, + val customerTyping: Boolean = false, + val messages: List = emptyList(), +) + +data class LiveChatSendRequest(val action: String = "send", val number: String, val message: String) +data class LiveChatSendResponse(val id: Int = 0, val createdAt: String = "", val status: String = "") +data class LiveChatActionRequest(val action: String, val number: String) + +// ─── Verfügbarkeit (Status) ─────────────────────────────────────────────────── + +data class TeamMemberDto( + val id: Int = 0, + val name: String = "", + val status: String = "", + val note: String = "", + val isOnVacation: Boolean = false, + val updatedAt: String? = null, +) + +data class AvailabilityResponse( + val myStatus: String = "", + val myNote: String = "", + val team: List = emptyList(), +) + +data class AvailabilitySetRequest(val status: String, val note: String) +data class AvailabilitySetResponse(val ok: Boolean = false, val status: String = "") + +// ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────────── + +data class KeyLabelDto(val value: String = "", val label: String = "") + +data class TicketRuleConditionDto( + val field: String = "", + val operator: String = "", + val value: String = "", +) + +data class TicketRuleActionDto( + val type: String = "", + val value: String = "", + val valueLabel: String? = null, +) + +data class TicketRuleDto( + val id: Int = 0, + val name: String = "", + val matchType: String = "", + val isActive: Boolean = false, + val sortOrder: Int = 0, + val conditions: List = emptyList(), + val actions: List = emptyList(), +) + +data class TicketRulesResponse( + val fieldOptions: List = emptyList(), + val operatorOptions: List = emptyList(), + val actionOptions: List = emptyList(), + val departments: List = emptyList(), + val priorities: List = emptyList(), + val helpTopics: List = emptyList(), + val staffOptions: List = emptyList(), + val cannedResponses: List = emptyList(), + val rules: List = emptyList(), + val autoCloseEnabled: Boolean = false, + val autoCloseDays: Int = 7, +) + +data class TicketRuleSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val matchType: String, + val isActive: Boolean, + val sortOrder: Int, + val conditions: List, + val actions: List, +) + +data class TicketRuleSaveResponse(val id: Int = 0) +data class TicketRuleIdRequest(val action: String, val id: Int) +data class AutoCloseSaveRequest(val action: String = "save_auto_close", val enabled: Boolean, val days: Int) + +// ─── Bild-Upload (z.B. für Signaturen) ─────────────────────────────────────── + +data class UploadImageResponse(val url: String = "") diff --git a/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt b/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt new file mode 100644 index 00000000..1a96a333 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt @@ -0,0 +1,117 @@ +package de.deskhub.staff.data.security + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import com.google.gson.Gson +import com.google.gson.JsonParseException +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +data class StoredSession( + val serverUrl: String = "", + val token: String = "", + val expiresAt: String = "", + val staffName: String = "", +) + +class SessionStorageException(message: String, cause: Throwable) : Exception(message, cause) + +class SecureSessionStore(context: Context) { + private val preferences = context.getSharedPreferences("deskhub_secure_session", Context.MODE_PRIVATE) + private val gson = Gson() + + fun save(session: StoredSession) { + check(preferences.edit().putString(PAYLOAD_KEY, encrypt(gson.toJson(session))).commit()) { + "Die geschützte Anmeldung konnte nicht gespeichert werden." + } + } + + fun load(): StoredSession? { + val encrypted = preferences.getString(PAYLOAD_KEY, null) ?: return null + return try { + gson.fromJson(decrypt(encrypted), StoredSession::class.java) + } catch (error: GeneralSecurityException) { + invalidate(error) + } catch (error: IllegalArgumentException) { + invalidate(error) + } catch (error: JsonParseException) { + invalidate(error) + } + } + + /** + * Löscht nur die geschützte Sitzung (Token), NICHT die zuletzt verwendete Server-Adresse – + * die bleibt bewusst erhalten, damit man sie nach einem Logout nicht erneut eintippen muss. + */ + fun clear() { + check(preferences.edit().remove(PAYLOAD_KEY).commit()) { + "Die lokale Anmeldung konnte nicht entfernt werden." + } + } + + /** Server-Adresse ist nicht geheim – wird unverschlüsselt gespeichert und überlebt clear(). */ + fun saveLastServerUrl(url: String) { + preferences.edit().putString(LAST_SERVER_URL_KEY, url).apply() + } + + fun loadLastServerUrl(): String? = preferences.getString(LAST_SERVER_URL_KEY, null) + + private fun encrypt(value: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val combined = cipher.iv + ciphertext + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + private fun decrypt(value: String): String { + val combined = Base64.decode(value, Base64.NO_WRAP) + require(combined.size > IV_LENGTH) { "Ungültige verschlüsselte Sitzungsdaten." } + val iv = combined.copyOfRange(0, IV_LENGTH) + val ciphertext = combined.copyOfRange(IV_LENGTH, combined.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv)) + return cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } + + private fun secretKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + generateKey() + } + } + + private fun invalidate(error: Throwable): Nothing { + check(preferences.edit().clear().commit()) { + "Beschädigte Sitzungsdaten konnten nicht entfernt werden." + } + throw SessionStorageException( + "Die geschützte Anmeldung konnte nicht gelesen werden und wurde entfernt.", + error, + ) + } + + private companion object { + const val KEY_ALIAS = "deskhub_staff_session_v1" + const val PAYLOAD_KEY = "payload" + const val LAST_SERVER_URL_KEY = "last_server_url" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_LENGTH = 12 + } +} diff --git a/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt b/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt new file mode 100644 index 00000000..016b29f7 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt @@ -0,0 +1,708 @@ +package de.deskhub.staff.domain.model + +data class Staff( + val id: Int, + val name: String, + val email: String, + val role: String, + val permissions: Map, +) + +data class NamedOption( + val id: Int, + val name: String, +) + +data class StatusOption( + val value: String, + val label: String, + val color: String, +) + +data class Priority( + val id: Int, + val name: String, + val color: String, +) + +data class TicketSummary( + val id: Int, + val number: String, + val subject: String, + val requesterName: String, + val requesterEmail: String, + val status: String, + val priority: Priority, + val department: NamedOption, + val assignedStaff: NamedOption?, + val isOverdue: Boolean, + /** Fertig formatierter Fälligkeitstext vom Server ("in 3 Std 12 Min" / "vor 2 Std 5 + * Min (überfällig)"), null wenn keine Fälligkeit gesetzt ist – siehe time_until() in + * includes/functions.php, dieselbe Quelle wie im Web, kein eigenes Datumsformat hier. */ + val dueLabel: String? = null, + val isSpam: Boolean, + val createdAt: String, + val updatedAt: String, +) + +data class ThreadAttachment( + val id: Int, + val name: String, + val size: Long, +) + +data class ThreadComment( + val id: Int, + val message: String, + val staff: NamedOption, + val createdAt: String, +) + +data class TicketThread( + val id: Int, + val authorType: String, + val authorName: String, + val message: String, + val isInternalNote: Boolean, + val createdAt: String, + val attachments: List, + val comments: List, +) + +data class TicketDetail( + val summary: TicketSummary, + val source: String, + val firstResponseDueAt: String?, + val resolutionDueAt: String?, + // Name des Mitarbeiters, der diesen Live-Chat gerade aktiv bearbeitet (Web oder + // App) – null wenn frei oder man selbst der Besitzer ist. Nur bei source == "chat" + // relevant; siehe live_chat_locked_by_other() in includes/functions.php. + val lockedByOtherName: String?, + val threads: List, + // Rechte für dieses Ticket (gelten pro Abteilung); null = ältere Serverversion -> globale Rechte + val permissions: Map? = null, +) + +data class SpamActionResult( + val ticket: TicketSummary, + val moved: Int, + val failed: List, +) + +data class Metadata( + val staff: Staff, + val departments: List, + val priorities: List, + val agents: List, + val statuses: List, + val tags: List = emptyList(), + val liveChatEnabled: Boolean = false, +) + +data class TicketPage( + val items: List, + val page: Int, + val total: Int, + val hasMore: Boolean, +) + +data class CannedResponse( + val id: Int, + val title: String, + val body: String, + val departmentId: Int?, +) + +data class DepartmentStat( + val name: String, + val open: Int, + val overdue: Int, +) + +data class DashboardStats( + val myOpen: Int, + val overdue: Int, + val unassigned: Int = 0, + val unanswered: Int = 0, + val resolvedToday: Int, + val slaPercent: Double?, + /** "Priorisiert": mir zugewiesen oder unbearbeitet, dieselbe Auswahl wie im Web-Dashboard. */ + val recent: List = emptyList(), + val departments: List?, +) + +data class Customer( + val id: Int?, + val name: String, + val email: String, + val organizationName: String?, + val ticketCount: Int, + val isGuest: Boolean, +) + +data class CustomerDetail( + val id: Int?, + val name: String, + val email: String, + val organizationName: String?, + val isGuest: Boolean, + val createdAt: String, +) + +data class CustomerProfile( + val customer: CustomerDetail, + val tickets: List, +) + +/** Systemhinweis des Servers (z. B. Mail-Abruf fällt aus); nur für Verwalter von E-Mail-Adressen sichtbar. */ +data class HealthIssue(val level: String, val message: String) { + val isError: Boolean get() = level == "error" +} + +data class ActivityUpdate( + val newReplies: Int, + val newUnassigned: Int, + val serverTime: String, + val health: List = emptyList(), +) + +data class TicketFilters( + val query: String = "", + val statuses: Set = setOf("open", "pending", "answered", "on_hold"), + val assigned: String = "", + val departmentId: Int? = null, + val priorityId: Int? = null, + val tagId: Int? = null, + // "" (alle) | "overdue" | "at_risk" | "on_time" – siehe staff/tickets.php für dieselbe Logik. + val sla: String = "", +) + +/** Gespeicherte Ticket-Ansicht (Filterkombination), eigene oder von Kollegen geteilte. */ +data class SavedView( + val id: Int, + val name: String, + val isShared: Boolean, + val mine: Boolean, + val ownerName: String?, + val filters: TicketFilters, +) + +// ─── Admin: Wissensdatenbank ──────────────────────────────────────────────── + +data class KbCategory( + val id: Int, + val name: String, +) + +data class KbArticle( + val id: Int, + val categoryId: Int, + val categoryName: String, + val title: String, + val body: String, + val isPublished: Boolean, + val views: Int, +) + +data class KbData( + val categories: List, + val articles: List, +) + +// ─── Admin: Zeiterfassung ──────────────────────────────────────────────────── + +data class TimeEntry( + val id: Int, + val staffName: String, + val ticketNumber: String, + val subject: String, + val minutes: Int, + val note: String?, + val loggedAt: String, +) + +data class TimeEntryTotal( + val staffName: String, + val minutes: Int, +) + +data class TimeEntriesReport( + val staffOptions: List, + val entries: List, + val totalsByStaff: List, + val totalMinutes: Int, +) + +// ─── Admin: Eskalationsregeln ──────────────────────────────────────────────── + +data class EscalationRule( + val id: Int, + val name: String, + val isActive: Boolean, + val triggerType: String, + val triggerHours: Int, + val conditionStatus: String?, + val conditionPriorityId: Int?, + val conditionPriorityName: String?, + val conditionDeptId: Int?, + val conditionDeptName: String?, + val actionSetPriorityId: Int?, + val actionSetPriorityName: String?, + val actionAssignStaffId: Int?, + val actionAssignStaffName: String?, + val actionSetStatus: String?, + val actionNotifyAdmin: Boolean, + val actionNotifyStaff: Boolean, + val sortOrder: Int, + val appliedCount: Int, +) + +data class EscalationData( + val priorities: List, + val staffOptions: List, + val departments: List, + val rules: List, +) + +// ─── Admin: Sperrliste ─────────────────────────────────────────────────────── + +data class BanEntry( + val id: Int, + val matchType: String, + val value: String, + val reason: String?, + val addedByName: String?, + val createdAt: String, +) + +// ─── Admin: Abteilungen ────────────────────────────────────────────────────── + +data class Department( + val id: Int, + val name: String, + val email: String, + val isDefault: Boolean, + val signature: String?, + val isActive: Boolean = true, + val managerId: Int? = null, + val managerName: String? = null, + val slaPlanId: Int? = null, + val notes: String? = null, + val members: List = emptyList(), + val updatedAt: String? = null, +) + +data class DepartmentMember(val id: Int, val name: String, val isPrimary: Boolean) + +data class DepartmentEmailOption(val email: String, val departmentName: String) + +/** Eingaben des Formulars „Abteilung“. */ +data class DepartmentInput( + val name: String, + val email: String, + val isDefault: Boolean, + val isActive: Boolean, + val managerId: Int?, + val slaPlanId: Int?, + val notes: String, + val signature: String?, +) + +data class DepartmentData( + val departments: List, + val emailOptions: List, + val staffOptions: List, + val slaPlans: List = emptyList(), +) + +data class SlaPlan( + val id: Int, + val name: String, + val responseHours: Int, + val resolutionHours: Int, + val isActive: Boolean, + val notes: String?, + val departmentCount: Int, + val helpTopicCount: Int, +) + +// ─── Admin: Prioritäten ────────────────────────────────────────────────────── + +data class PriorityFull( + val id: Int, + val name: String, + val color: String, + val responseHours: Int, + val resolutionHours: Int, + val sortOrder: Int, +) + +// ─── Admin: Hilfe-Themen ───────────────────────────────────────────────────── + +data class HelpTopic( + val id: Int, + val name: String, + val departmentId: Int, + val departmentName: String, + val priorityId: Int, + val priorityName: String, + val priorityColor: String, + val isActive: Boolean, + val sortOrder: Int, + val isPublic: Boolean = true, + val parentId: Int? = null, + val slaPlanId: Int? = null, + val path: String = "", + val notes: String? = null, + val updatedAt: String? = null, +) + +/** Eingaben des Formulars „Hilfethema“ (Informationen + Optionen bei neuem Ticket). */ +data class HelpTopicInput( + val name: String, + val isActive: Boolean, + val isPublic: Boolean, + val parentId: Int?, + val slaPlanId: Int?, + val notes: String, + val departmentId: Int, + val priorityId: Int, + val sortOrder: Int, +) + +data class HelpTopicData( + val departments: List, + val priorities: List, + val slaPlans: List = emptyList(), + val topics: List, +) + +// ─── Admin: Organisationen ─────────────────────────────────────────────────── + +data class Organization( + val id: Int, + val name: String, + val notes: String?, + val memberCount: Int, +) + +// ─── Admin: Teams ──────────────────────────────────────────────────────────── + +data class Team( + val id: Int, + val name: String, + val leadId: Int?, + val leadName: String?, + val notes: String?, + val isActive: Boolean, + val memberCount: Int, + val memberIds: List, +) + +data class TeamData( + val staffOptions: List, + val teams: List, +) + +// ─── Admin: Rollen ─────────────────────────────────────────────────────────── + +data class Role( + val id: Int, + val name: String, + val permissions: Map, + val staffCount: Int, +) + +data class RoleData( + val permissionKeys: List, + val defaultAgentPermissions: Map, + val roles: List, +) + +// ─── Admin: Mitarbeiter ────────────────────────────────────────────────────── + +data class StaffExtraDept( + val departmentId: Int, + val notify: Boolean, + /** Rolle in dieser Abteilung; null = Hauptrolle des Mitarbeiters. */ + val roleId: Int? = null, +) + +data class StaffAdmin( + val id: Int, + val name: String, + val email: String, + val role: String, + val roleId: Int?, + val departmentId: Int?, + val departmentName: String?, + val isActive: Boolean, + val signature: String?, + val extraDepartments: List, + val teamIds: List = emptyList(), + val createdAt: String? = null, +) + +data class StaffAdminData( + val departments: List, + val roles: List, + val staff: List, + val teams: List = emptyList(), + val canManageTeams: Boolean = false, +) + +/** Eingaben des Formulars „Agent“ (Konto, Zugang, Berechtigungen, Teams). */ +data class StaffAdminInput( + val name: String, + val email: String, + val role: String, + val roleId: Int?, + val departmentId: Int?, + val isActive: Boolean, + val password: String, + val signature: String?, + val extraDepartments: List, + val teamIds: List?, +) + +// ─── Admin: Geschäftszeiten ────────────────────────────────────────────────── + +data class BusinessHour( + val weekday: Int, + val isOpen: Boolean, + val openTime: String, + val closeTime: String, +) + +data class BusinessHoliday( + val id: Int, + val date: String, + val name: String, +) + +data class BusinessHoursData( + val enabled: Boolean, + val hours: List, + val holidays: List, +) + +// ─── Admin: E-Mail-Adressen ────────────────────────────────────────────────── + +data class EmailAddress( + val id: Int, + val email: String, + val label: String?, + val departmentId: Int, + val departmentName: String, + val priorityId: Int?, + val priorityName: String?, + val isDefault: Boolean, + val helpTopicId: Int? = null, + val isActive: Boolean = true, + val notes: String? = null, + val smtpEnabled: Boolean = false, + val smtpHost: String = "", + val smtpPort: Int = 587, + val smtpEncryption: String = "tls", + val smtpUsername: String = "", + val smtpHasPassword: Boolean = false, + val imapEnabled: Boolean = false, + val imapHost: String = "", + val imapPort: Int = 993, + val imapEncryption: String = "ssl", + val imapUsername: String = "", + val imapFolder: String = "INBOX", + val imapAfterFetch: String = "seen", + val imapArchiveFolder: String = "Processed", + val imapSpamFolder: String = "Junk", + val imapHasPassword: Boolean = false, + val imapLastRunAt: String? = null, +) + +/** Eingaben des Formulars „E-Mail-Adresse“ (Konto + entferntes Postfach). */ +data class EmailAddressInput( + val email: String, + val label: String?, + val departmentId: Int, + val priorityId: Int?, + val isDefault: Boolean, + val helpTopicId: Int?, + val isActive: Boolean, + val notes: String, + val smtpEnabled: Boolean, + val smtpHost: String, + val smtpPort: Int, + val smtpEncryption: String, + val smtpUsername: String, + val smtpPassword: String, + val imapEnabled: Boolean, + val imapHost: String, + val imapPort: Int, + val imapEncryption: String, + val imapUsername: String, + val imapPassword: String, + val imapFolder: String, + val imapAfterFetch: String, + val imapArchiveFolder: String, + val imapSpamFolder: String, +) + +data class EmailAddressData( + val departments: List, + val priorities: List, + val helpTopics: List = emptyList(), + val addresses: List, +) + +// ─── Admin: E-Mail-Vorlagen ────────────────────────────────────────────────── + +data class EmailTemplate( + val key: String, + val label: String, + val placeholders: List, + val subject: String, + val body: String, + val isCustomized: Boolean, +) + +// ─── Admin: Admin-Protokoll ────────────────────────────────────────────────── + +data class AuditLogEntry( + val id: Int, + val staffName: String?, + val action: String, + val createdAt: String, +) + +data class AuditLogPage( + val items: List, + val page: Int, + val hasMore: Boolean, +) + +// ─── Aufgaben ──────────────────────────────────────────────────────────────── + +data class StaffTask( + val id: Int, + val title: String, + val description: String?, + val status: String, + val assignedToId: Int?, + val assignedName: String?, + val creatorName: String?, + val dueAt: String?, + val ticketNumber: String?, + val ticketSubject: String?, +) + +data class TaskData( + val staffOptions: List, + val tasks: List, +) + +// ─── Interner Chat ──────────────────────────────────────────────────────────── + +data class ChatContact( + val id: Int, + val name: String, + val unreadCount: Int, + val lastMessageAt: String?, +) + +data class ChatMessage( + val id: Int, + val fromStaffId: Int, + val fromName: String, + val message: String, + val createdAt: String, +) + +data class ChatConversation( + val with: NamedOption, + val messages: List, +) + +// ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────────── + +data class LiveChatSummary( + val number: String, + val name: String, + val preview: String, + val needsReply: Boolean, + val updatedAt: String, +) + +data class LiveChatAttachment( + val id: Int, + val name: String, + val size: Long, +) + +data class LiveChatMessage( + val id: Int, + val authorType: String, + val authorName: String?, + val message: String, + val createdAt: String, + val attachments: List, +) + +data class LiveChatDetail( + val number: String, + val status: String, + val requesterName: String, + val requesterEmail: String, + val departmentName: String, + val lockedByOtherName: String?, + val customerTyping: Boolean, + val messages: List, +) + +// ─── Verfügbarkeit (Status) ──────────────────────────────────────────────────── + +data class TeamMember( + val id: Int, + val name: String, + val status: String, + val note: String, + val isOnVacation: Boolean, + val updatedAt: String?, +) + +data class AvailabilityData( + val myStatus: String, + val myNote: String, + val team: List, +) + +// ─── Ticket-Regeln (Automatisierung) ────────────────────────────────────────── + +data class KeyLabel(val value: String, val label: String) + +data class TicketRuleCondition(val field: String, val operator: String, val value: String) + +data class TicketRuleAction(val type: String, val value: String, val valueLabel: String?) + +data class TicketRule( + val id: Int, + val name: String, + val matchType: String, + val isActive: Boolean, + val sortOrder: Int, + val conditions: List, + val actions: List, +) + +data class TicketRulesData( + val fieldOptions: List, + val operatorOptions: List, + val actionOptions: List, + val departments: List, + val priorities: List, + val helpTopics: List, + val staffOptions: List, + val cannedResponses: List, + val rules: List, + val autoCloseEnabled: Boolean, + val autoCloseDays: Int, +) diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt new file mode 100644 index 00000000..c438f5c1 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt @@ -0,0 +1,6206 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package de.deskhub.staff.ui + +import android.graphics.Color.parseColor +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Assignment +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.Rule +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.AdminPanelSettings +import androidx.compose.material.icons.filled.Apartment +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Assessment +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ConfirmationNumber +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SupportAgent +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.Shapes +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.toArgb +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.BrightnessMedium +import androidx.compose.material3.Typography +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import de.deskhub.staff.R +import de.deskhub.staff.domain.model.AuditLogEntry +import de.deskhub.staff.domain.model.BanEntry +import de.deskhub.staff.domain.model.BusinessHour +import de.deskhub.staff.domain.model.Department +import de.deskhub.staff.domain.model.DepartmentInput +import de.deskhub.staff.domain.model.EmailAddress +import de.deskhub.staff.domain.model.EmailAddressInput +import de.deskhub.staff.domain.model.EmailTemplate +import de.deskhub.staff.domain.model.EscalationRule +import de.deskhub.staff.domain.model.HelpTopic +import de.deskhub.staff.domain.model.HelpTopicInput +import de.deskhub.staff.domain.model.KeyLabel +import de.deskhub.staff.domain.model.Organization +import de.deskhub.staff.domain.model.PriorityFull +import de.deskhub.staff.domain.model.Role +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StaffAdmin +import de.deskhub.staff.domain.model.StaffAdminInput +import de.deskhub.staff.domain.model.StaffExtraDept +import de.deskhub.staff.domain.model.StaffTask +import de.deskhub.staff.domain.model.Team +import de.deskhub.staff.domain.model.KbArticle +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.NamedOption +import de.deskhub.staff.domain.model.TicketDetail +import de.deskhub.staff.domain.model.TicketFilters +import de.deskhub.staff.domain.model.TicketRule +import de.deskhub.staff.domain.model.TicketRuleAction +import de.deskhub.staff.domain.model.TicketRuleCondition +import de.deskhub.staff.domain.model.TicketRulesData +import de.deskhub.staff.domain.model.TicketSummary + +// ─── Brand palette ────────────────────────────────────────────────────────── +private val BrandTeal = Color(0xFF1F8FE0) // Blau (Akzent) +private val BrandTealDark = Color(0xFF28405A) // dunkles Marineblau (Kopf-/Navigationsleisten) +private val BrandInk = Color(0xFF1C2733) +private val BrandAmber = Color(0xFFD98200) + +private val DeskHubLightColors = lightColorScheme( + primary = Color(0xFF1F8FE0), + onPrimary = Color.White, + primaryContainer = Color(0xFFD6EAFB), + onPrimaryContainer = Color(0xFF0B3A63), + secondary = Color(0xFF5B6B7D), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE4EAF1), + onSecondaryContainer = Color(0xFF1F2A36), + tertiary = BrandAmber, + onTertiary = Color.White, + tertiaryContainer = Color(0xFFFBE4C4), + onTertiaryContainer = Color(0xFF5A3200), + background = Color(0xFFEEF1F5), + onBackground = BrandInk, + surface = Color.White, + onSurface = BrandInk, + surfaceVariant = Color(0xFFE9EDF2), + onSurfaceVariant = Color(0xFF667384), + surfaceTint = Color(0xFF1F8FE0), + surfaceContainerLowest = Color.White, + surfaceContainerLow = Color(0xFFF7F9FB), + surfaceContainer = Color(0xFFF1F4F7), + surfaceContainerHigh = Color(0xFFEBEFF3), + surfaceContainerHighest = Color.White, // Standard-Karten: weiß auf hellem Grund + outline = Color(0xFFD0D7DF), + outlineVariant = Color(0xFFE3E8EE), + error = Color(0xFFD63B3B), + onError = Color.White, + errorContainer = Color(0xFFFDE2E2), + onErrorContainer = Color(0xFF7A1F1F), +) + +private val DeskHubDarkColors = darkColorScheme( + primary = Color(0xFF3EA6F0), + onPrimary = Color(0xFF06192B), + primaryContainer = Color(0xFF123B5E), + onPrimaryContainer = Color(0xFFD3E9FB), + secondary = Color(0xFFA9B9CB), + onSecondary = Color(0xFF16212D), + secondaryContainer = Color(0xFF22313F), + onSecondaryContainer = Color(0xFFDCE6F0), + tertiary = Color(0xFFF0A63A), + onTertiary = Color(0xFF3A2600), + tertiaryContainer = Color(0xFF5C3D08), + onTertiaryContainer = Color(0xFFFFDDB0), + background = Color(0xFF0D141D), + onBackground = Color(0xFFE7EDF4), + surface = Color(0xFF16212D), + onSurface = Color(0xFFE7EDF4), + surfaceVariant = Color(0xFF1B2836), + onSurfaceVariant = Color(0xFF8C9BAD), + surfaceTint = Color(0xFF3EA6F0), + surfaceContainerLowest = Color(0xFF0A1119), + surfaceContainerLow = Color(0xFF101A25), + surfaceContainer = Color(0xFF141F2B), + surfaceContainerHigh = Color(0xFF1B2836), + surfaceContainerHighest = Color(0xFF1A2634), // Standard-Karten: leicht heller als der Grund + outline = Color(0xFF2A3B4E), + outlineVariant = Color(0xFF213040), + error = Color(0xFFF06B6B), + onError = Color(0xFF3A0A0A), + errorContainer = Color(0xFF5E1713), + onErrorContainer = Color(0xFFFFDAD6), +) + +/** Farbe der Kopf- und Navigationsleisten: dunkles Marineblau (im Dunkelmodus noch dunkler). */ +val LocalChrome = staticCompositionLocalOf { Color(0xFF28405A) } +private val ChromeLight = Color(0xFF28405A) +private val ChromeDark = Color(0xFF0A1119) +private val ChromeMuted = Color(0xFFA9B9CB) + +/** Design-Modus der App: folgt dem System oder ist fest Hell/Dunkel (wird lokal gespeichert). */ +enum class ThemeMode(val label: String) { System("Wie System"), Light("Hell"), Dark("Dunkel") } + +class ThemeController(val mode: ThemeMode, val setMode: (ThemeMode) -> Unit) + +val LocalThemeController = staticCompositionLocalOf { ThemeController(ThemeMode.System) { } } + +private fun loadThemeMode(context: android.content.Context): ThemeMode = + runCatching { ThemeMode.valueOf(context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).getString("theme", null) ?: "System") } + .getOrDefault(ThemeMode.System) + +private fun saveThemeMode(context: android.content.Context, mode: ThemeMode) { + context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).edit().putString("theme", mode.name).apply() +} + +private val DeskHubShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(10.dp), + medium = RoundedCornerShape(14.dp), + large = RoundedCornerShape(20.dp), + extraLarge = RoundedCornerShape(28.dp), +) + +// Ruhigere, klarere Schrift: kräftigere Titel, großzügigere Zeilenhöhe im Fließtext. +private val DeskHubTypography = Typography( + headlineMedium = TextStyle(fontSize = 26.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp), + headlineSmall = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp), + titleLarge = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.1).sp), + titleMedium = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold), + titleSmall = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), + bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp), + bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 21.sp), + bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 17.sp), + labelLarge = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), + labelMedium = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), + labelSmall = TextStyle(fontSize = 11.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.2.sp), +) + +@Composable +fun DeskHubApp(viewModel: DeskHubViewModel) { + val state by viewModel.state.collectAsState() + val context = androidx.compose.ui.platform.LocalContext.current + var themeMode by remember { mutableStateOf(loadThemeMode(context)) } + val dark = when (themeMode) { + ThemeMode.System -> isSystemInDarkTheme() + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + val colorScheme = if (dark) DeskHubDarkColors else DeskHubLightColors + val chrome = if (dark) ChromeDark else ChromeLight + val controller = remember(themeMode) { ThemeController(themeMode) { themeMode = it; saveThemeMode(context, it) } } + + // Statusleiste/Navigationsleiste an das gewählte Design anpassen (auch wenn es vom System abweicht) + val view = androidx.compose.ui.platform.LocalView.current + androidx.compose.runtime.SideEffect { + (view.context as? android.app.Activity)?.window?.let { window -> + window.statusBarColor = chrome.toArgb() + window.navigationBarColor = chrome.toArgb() + val insets = androidx.core.view.WindowCompat.getInsetsController(window, view) + insets.isAppearanceLightStatusBars = false + insets.isAppearanceLightNavigationBars = false + } + } + + androidx.compose.runtime.CompositionLocalProvider(LocalThemeController provides controller, LocalChrome provides chrome) { + MaterialTheme(colorScheme = colorScheme, shapes = DeskHubShapes, typography = DeskHubTypography) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + when { + state.initializing -> LoadingState("Anmeldung wird geprüft …") + state.staff == null -> LoginScreen(state, viewModel) + else -> StaffNavigation(state, viewModel) + } + } + } + } +} + +/** Kleines Menü zum Umschalten zwischen System/Hell/Dunkel (für Kopfleisten). */ +@Composable +internal fun ThemeMenuButton() { + val controller = LocalThemeController.current + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }) { + Icon( + when (controller.mode) { + ThemeMode.Dark -> Icons.Filled.DarkMode + ThemeMode.Light -> Icons.Filled.LightMode + ThemeMode.System -> Icons.Filled.BrightnessMedium + }, + contentDescription = "Design wechseln", + ) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + ThemeMode.values().forEach { mode -> + DropdownMenuItem( + text = { Text(mode.label, fontWeight = if (mode == controller.mode) FontWeight.Bold else FontWeight.Normal) }, + onClick = { controller.setMode(mode); open = false }, + ) + } + } + } +} + + +@Composable +private fun chromeNavColors() = androidx.compose.material3.NavigationBarItemDefaults.colors( + selectedIconColor = Color.White, + selectedTextColor = Color.White, + indicatorColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = ChromeMuted, + unselectedTextColor = ChromeMuted, +) + +private val TopLevelRoutes = setOf("tickets", "dashboard", "customers", "chat", "admin") + +@Composable +private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) { + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + + Scaffold( + bottomBar = { + if (currentRoute in TopLevelRoutes) { + // Seit Chat + Live-Chat zu einem Tab zusammengelegt wurden (ChatHubScreen), + // sind es nur noch 5 statt 6 Einträge – dadurch passt auf normalen Handy- + // Breiten wieder ein Text-Label neben jedes Icon, ohne dass etwas umbricht. + // Übersicht steht bewusst an erster Stelle (und ist auch Startbildschirm, + // siehe startDestination unten) – das ist der Einstiegspunkt in den Tag. + NavigationBar(containerColor = LocalChrome.current, tonalElevation = 0.dp) { + NavigationBarItem( + colors = chromeNavColors(), + selected = currentRoute == "dashboard", + onClick = { navController.navigateToTab("dashboard") }, + icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Übersicht") }, + label = { Text("Übersicht") }, + ) + NavigationBarItem( + colors = chromeNavColors(), + selected = currentRoute == "tickets", + onClick = { + viewModel.clearActivityBadge() + navController.navigateToTab("tickets") + }, + icon = { + BadgedBox(badge = { + if (state.activityBadgeCount > 0) { + Badge { Text(state.activityBadgeCount.toString()) } + } + }) { + Icon(Icons.Filled.ConfirmationNumber, contentDescription = "Tickets") + } + }, + label = { Text("Tickets") }, + ) + NavigationBarItem( + colors = chromeNavColors(), + selected = currentRoute == "customers", + onClick = { navController.navigateToTab("customers") }, + icon = { Icon(Icons.Filled.People, contentDescription = "Kunden") }, + label = { Text("Kunden") }, + ) + NavigationBarItem( + colors = chromeNavColors(), + selected = currentRoute == "chat", + onClick = { navController.navigateToTab("chat") }, + icon = { + // Interner Chat + Live-Chat teilen sich jetzt einen Tab (siehe + // ChatHubScreen) – das Badge zählt deshalb beides zusammen, damit + // man auch von "Team" aus sieht, dass ein Kunde wartet (und umgekehrt). + val unread = state.chatContacts?.sumOf { it.unreadCount } ?: 0 + val needsReply = state.liveChats?.count { it.needsReply } ?: 0 + val total = unread + needsReply + BadgedBox(badge = { if (total > 0) Badge { Text(total.toString()) } }) { + Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "Chat") + } + }, + label = { Text("Chat") }, + ) + if (state.staff.hasAnyAdminAccess()) { + NavigationBarItem( + colors = chromeNavColors(), + selected = currentRoute == "admin", + onClick = { navController.navigateToTab("admin") }, + icon = { Icon(Icons.Filled.AdminPanelSettings, contentDescription = "Verwaltung") }, + label = { Text("Verwaltung") }, + ) + } + } + } + }, + ) { outerPadding -> + Column(Modifier.padding(outerPadding)) { + StatusBanners(state, onRetry = viewModel::retryConnection) + NavHost( + navController = navController, + startDestination = "dashboard", + modifier = Modifier.weight(1f), + ) { + composable("tickets") { + TicketListScreen( + state = state, + onFiltersChanged = viewModel::updateFilters, + onSearch = viewModel::refreshTickets, + onRefresh = viewModel::refreshTickets, + onLoadMore = viewModel::loadMoreTickets, + onApplyView = viewModel::applySavedView, + onSaveView = viewModel::saveCurrentView, + onDeleteView = viewModel::deleteSavedView, + onLoadViews = viewModel::loadSavedViews, + onTicket = { number -> + viewModel.openTicket(number) + navController.navigate("detail") + }, + onLogout = viewModel::logout, + onNewTicket = { + viewModel.startNewTicket() + navController.navigate("newTicket") + }, + ) + } + composable("dashboard") { + DashboardScreen( + state = state, + onLoad = { + viewModel.loadDashboard() + viewModel.loadAvailability() + }, + onSetStatus = viewModel::setMyAvailability, + onTicket = { number -> + viewModel.openTicket(number) + navController.navigate("detail") + }, + ) + } + composable("customers") { + CustomersScreen( + state = state, + onSearch = viewModel::searchCustomers, + onCustomer = { customer -> + viewModel.openCustomer(customer) + navController.navigate("customerProfile") + }, + ) + } + composable("customerProfile") { + CustomerProfileScreen( + state = state, + onBack = { navController.popBackStack() }, + onTicket = { number -> + viewModel.openTicket(number) + navController.navigate("detail") + }, + onNewTicket = { name, email -> + viewModel.startNewTicket(name, email) + navController.navigate("newTicket") + }, + ) + } + composable("chat") { + ChatHubScreen( + state = state, + onLoadContacts = viewModel::loadChatContacts, + onOpenContact = { contact -> + viewModel.openChatConversation(contact.id) + navController.navigate("chatConversation") + }, + onLoadLiveChats = viewModel::loadLiveChats, + onStopLiveChatPolling = viewModel::stopLiveChatListPolling, + onOpenLiveChat = { number -> + viewModel.openLiveChat(number) + navController.navigate("liveChatConversation") + }, + ) + } + composable("chatConversation") { + ChatConversationScreen( + state = state, + onBack = { viewModel.closeChatConversation(); navController.popBackStack() }, + onSend = viewModel::sendChatMessage, + onClearError = viewModel::clearChatSendError, + ) + } + composable("liveChatConversation") { + LiveChatConversationScreen( + state = state, + onBack = { viewModel.closeLiveChat(); navController.popBackStack() }, + onSend = viewModel::sendLiveChatMessage, + onTyping = viewModel::pingLiveChatTyping, + onClearError = viewModel::clearLiveChatSendError, + onEnd = { number -> viewModel.endLiveChat(number) { navController.popBackStack() } }, + ) + } + composable("newTicket") { + NewTicketScreen( + state = state, + onBack = { navController.popBackStack() }, + onCreate = { requesterName, requesterEmail, subject, message, departmentId, priorityId, assignedStaffId, notify -> + viewModel.createTicket( + requesterName, requesterEmail, subject, message, + departmentId, priorityId, assignedStaffId, notify, + ) { number -> + viewModel.openTicket(number) + navController.navigate("detail") { + popUpTo("tickets") + } + } + }, + ) + } + composable("detail") { + TicketDetailScreen( + state = state, + onBack = { navController.popBackStack() }, + onRetry = { state.selectedTicket?.summary?.number?.let(viewModel::openTicket) }, + onUpdate = viewModel::updateTicket, + onMessage = viewModel::addMessage, + onSpamAction = viewModel::setTicketSpam, + ) + } + composable("admin") { + AdminScreen(staff = state.staff, onNavigate = { route -> navController.navigate(route) }) + } + composable("adminKb") { + AdminKbScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadKb, + onSaveArticle = viewModel::saveKbArticle, + onDeleteArticle = viewModel::deleteKbArticle, + onCreateCategory = viewModel::createKbCategory, + onDeleteCategory = viewModel::deleteKbCategory, + onClearError = viewModel::clearKbMutationError, + ) + } + composable("adminTimeEntries") { + AdminTimeEntriesScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadTimeEntries, + ) + } + composable("adminEscalation") { + AdminEscalationScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadEscalationRules, + onSave = viewModel::saveEscalationRule, + onDelete = viewModel::deleteEscalationRule, + onToggle = viewModel::toggleEscalationRule, + onClearError = viewModel::clearEscalationMutationError, + ) + } + composable("adminTicketRules") { + AdminTicketRulesScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadTicketRules, + onSave = viewModel::saveTicketRule, + onDelete = viewModel::deleteTicketRule, + onToggle = viewModel::toggleTicketRule, + onClearError = viewModel::clearTicketRulesMutationError, + onSaveAutoClose = viewModel::saveAutoClose, + ) + } + composable("adminAvailability") { + AdminAvailabilityScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadAvailability, + onSetStatus = viewModel::setMyAvailability, + ) + } + composable("adminBanlist") { + AdminBanlistScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadBanlist, + onCreate = viewModel::createBanEntry, + onBulkAdd = viewModel::bulkAddBanEntries, + onDelete = viewModel::deleteBanEntry, + onClearError = viewModel::clearBanlistMutationError, + onClearBulkResult = viewModel::clearBanlistBulkResult, + ) + } + composable("adminDepartments") { + AdminDepartmentsScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadDepartments, + onSave = viewModel::saveDepartment, + onDelete = viewModel::deleteDepartment, + onClearError = viewModel::clearDepartmentsError, + onUploadImage = viewModel::uploadSignatureImage, + ) + } + composable("adminReports") { + AdminReportsScreen( + state = state, + onBack = { navController.popBackStack() }, + onRun = viewModel::runReport, + onSave = viewModel::saveReport, + onDelete = viewModel::deleteReport, + ) + } + composable("adminSlaPlans") { + AdminSlaPlansScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadSlaPlans, + onSave = viewModel::saveSlaPlan, + onDelete = viewModel::deleteSlaPlan, + onClearError = viewModel::clearSlaPlansError, + ) + } + composable("adminPriorities") { + AdminPrioritiesScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadPrioritiesAdmin, + onSave = viewModel::savePriority, + onDelete = viewModel::deletePriority, + onClearError = viewModel::clearPrioritiesError, + ) + } + composable("adminHelpTopics") { + AdminHelpTopicsScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadHelpTopics, + onSave = viewModel::saveHelpTopic, + onDelete = viewModel::deleteHelpTopic, + onClearError = viewModel::clearHelpTopicsError, + ) + } + composable("adminOrganizations") { + AdminOrganizationsScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadOrganizations, + onSave = viewModel::saveOrganization, + onDelete = viewModel::deleteOrganization, + onClearError = viewModel::clearOrganizationsError, + ) + } + composable("adminTeams") { + AdminTeamsScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadTeams, + onSave = viewModel::saveTeam, + onToggle = viewModel::toggleTeam, + onDelete = viewModel::deleteTeam, + onClearError = viewModel::clearTeamsError, + ) + } + composable("adminRoles") { + AdminRolesScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadRoles, + onSave = viewModel::saveRole, + onDelete = viewModel::deleteRole, + onClearError = viewModel::clearRolesError, + ) + } + composable("adminStaff") { + AdminStaffScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadStaffAdmin, + onSave = viewModel::saveStaffAdmin, + onDelete = viewModel::deleteStaffAdmin, + onClearError = viewModel::clearStaffAdminError, + onUploadImage = viewModel::uploadSignatureImage, + ) + } + composable("adminBusinessHours") { + AdminBusinessHoursScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadBusinessHours, + onSaveHours = viewModel::saveBusinessHours, + onAddHoliday = viewModel::addBusinessHoliday, + onDeleteHoliday = viewModel::deleteBusinessHoliday, + ) + } + composable("adminEmailAddresses") { + AdminEmailAddressesScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadEmailAddresses, + onSave = viewModel::saveEmailAddress, + onDelete = viewModel::deleteEmailAddress, + onTest = viewModel::testEmailAddress, + onClearError = viewModel::clearEmailAddressesError, + ) + } + composable("adminEmailTemplates") { + AdminEmailTemplatesScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadEmailTemplates, + onSave = { key, subject, bodyText -> viewModel.saveEmailTemplate(key, subject, bodyText) {} }, + onReset = viewModel::resetEmailTemplate, + ) + } + composable("adminAuditLog") { + AdminAuditLogScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadAuditLog, + ) + } + composable("adminTasks") { + AdminTasksScreen( + state = state, + onBack = { navController.popBackStack() }, + onLoad = viewModel::loadTasks, + onCreate = viewModel::createTask, + onUpdateStatus = viewModel::updateTaskStatus, + onDelete = viewModel::deleteTask, + onClearError = viewModel::clearTasksError, + ) + } + } + } + } +} + +/** Banner unter der Kopfleiste: Verbindungsausfall und Systemhinweise des Servers (z. B. Mail-Abruf). */ +@Composable +internal fun StatusBanners(state: DeskHubState, onRetry: () -> Unit) { + if (state.offline) { + Surface(color = MaterialTheme.colorScheme.error, modifier = Modifier.fillMaxWidth()) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + "Keine Verbindung zum Server. Es wird automatisch erneut versucht.", + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onError, + style = MaterialTheme.typography.labelLarge, + ) + TextButton(onClick = onRetry) { Text("Erneut", color = MaterialTheme.colorScheme.onError) } + } + } + } + state.healthIssues.forEach { issue -> + Surface( + color = if (issue.isError) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.tertiaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + (if (issue.isError) "Störung: " else "Hinweis: ") + issue.message, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = if (issue.isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onTertiaryContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +private fun androidx.navigation.NavController.navigateToTab(route: String) { + navigate(route) { + popUpTo(graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } +} + +// ─── Login ────────────────────────────────────────────────────────────────── + +@Composable +private fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) { + var serverUrl by rememberSaveable { mutableStateOf(state.savedServerUrl ?: "") } + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var totpCode by rememberSaveable { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .background( + Brush.linearGradient(listOf(Color(0xFF14202E), BrandTealDark, Color(0xFF1F6FB5))), + ), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.SupportAgent, contentDescription = null, tint = Color.White) + } + Spacer(Modifier.height(12.dp)) + Text( + "DeskHub", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + Text( + "Mitarbeiter-Bereich", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.75f), + ) + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + shape = MaterialTheme.shapes.large, + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + if (state.totpRequired) "Zweiten Faktor bestätigen" else "Mit deinem Mitarbeiterkonto anmelden", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (!state.totpRequired) { + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.server_label)) }, + placeholder = { Text("https://support.example.de") }, + leadingIcon = { Icon(Icons.Filled.Business, contentDescription = null) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("E-Mail-Adresse") }, + leadingIcon = { Icon(Icons.Filled.Email, contentDescription = null) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Passwort") }, + leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { viewModel.login(serverUrl, email, password, "") }, + ), + ) + } else { + OutlinedTextField( + value = totpCode, + onValueChange = { value -> totpCode = value.filter(Char::isDigit).take(6) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Authenticator-Code") }, + leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { viewModel.login(serverUrl, email, password, totpCode) }, + ), + ) + } + state.authError?.let { ErrorText(it) } + Button( + onClick = { + viewModel.login( + serverUrl = serverUrl, + email = email, + password = password, + totpCode = if (state.totpRequired) totpCode else "", + ) + }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = MaterialTheme.shapes.medium, + enabled = !state.authLoading && + serverUrl.isNotBlank() && email.isNotBlank() && password.isNotBlank() && + (!state.totpRequired || totpCode.length == 6), + ) { + if (state.authLoading) { + CircularProgressIndicator(modifier = Modifier.width(20.dp), strokeWidth = 2.dp) + } else { + Text(if (state.totpRequired) "Bestätigen" else "Anmelden") + } + } + if (state.totpRequired) { + TextButton( + onClick = { + totpCode = "" + password = "" + viewModel.cancelTotp() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Zurück") + } + } + } + } + } +} + +// ─── Ticket list ──────────────────────────────────────────────────────────── + +@Composable +private fun TicketListScreen( + state: DeskHubState, + onFiltersChanged: (TicketFilters) -> Unit, + onSearch: () -> Unit, + onRefresh: () -> Unit, + onLoadMore: () -> Unit, + onApplyView: (de.deskhub.staff.domain.model.SavedView) -> Unit, + onSaveView: (String, Boolean) -> Unit, + onDeleteView: (Int) -> Unit, + onLoadViews: () -> Unit, + onTicket: (String) -> Unit, + onLogout: () -> Unit, + onNewTicket: () -> Unit, +) { + LaunchedEffect(Unit) { onLoadViews() } + val canCreateTicket = state.metadata?.staff?.permissions?.get("create_ticket") == true + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Tickets", fontWeight = FontWeight.Bold) + Text( + "${state.ticketsTotal} gesamt", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + state.staff?.let { staff -> + Text( + "Angemeldet als ${staff.name} (${staff.email}) · ${if (staff.role == "admin") "Administrator" else "Mitarbeiter"} · App v${de.deskhub.staff.BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.65f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + actions = { + ThemeMenuButton() + IconButton(onClick = onLogout, enabled = !state.mutationLoading) { + Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Abmelden") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + actionIconContentColor = Color.White, + ), + ) + }, + floatingActionButton = { + if (canCreateTicket) { + FloatingActionButton(onClick = onNewTicket) { + Icon(Icons.Filled.Add, contentDescription = "Neues Ticket") + } + } + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + ) { + TicketFilterBar( + filters = state.filters, + metadata = state.metadata, + onFiltersChanged = onFiltersChanged, + onSearch = onSearch, + savedViews = state.savedViews, + onApplyView = onApplyView, + onSaveView = onSaveView, + onDeleteView = onDeleteView, + ) + state.mutationError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) } + when { + state.ticketsLoading && state.tickets.isEmpty() -> LoadingState("Tickets werden geladen …") + state.ticketsError != null && state.tickets.isEmpty() -> ErrorState(state.ticketsError, onRefresh) + state.tickets.isEmpty() -> EmptyState("Keine Tickets für diese Filter.") + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(state.tickets, key = TicketSummary::id) { ticket -> + TicketCard(ticket, state.metadata, onTicket) + } + item { + if (state.ticketsError != null) { + ErrorText(state.ticketsError) + } + if (state.ticketsHasMore) { + OutlinedButton( + onClick = onLoadMore, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + enabled = !state.ticketsLoading, + ) { + Text(if (state.ticketsLoading) "Wird geladen …" else "Weitere laden") + } + } else { + TextButton(onClick = onRefresh, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Aktualisieren") + } + } + } + } + } + } + } +} + +@Composable +private fun TicketFilterBar( + filters: TicketFilters, + metadata: Metadata?, + onFiltersChanged: (TicketFilters) -> Unit, + onSearch: () -> Unit, + savedViews: List = emptyList(), + onApplyView: (de.deskhub.staff.domain.model.SavedView) -> Unit = {}, + onSaveView: (String, Boolean) -> Unit = { _, _ -> }, + onDeleteView: (Int) -> Unit = {}, +) { + var viewsExpanded by remember { mutableStateOf(false) } + var showSaveDialog by remember { mutableStateOf(false) } + var statusExpanded by remember { mutableStateOf(false) } + var assignedExpanded by remember { mutableStateOf(false) } + var departmentExpanded by remember { mutableStateOf(false) } + var priorityExpanded by remember { mutableStateOf(false) } + var tagExpanded by remember { mutableStateOf(false) } + var slaExpanded by remember { mutableStateOf(false) } + val activeStatuses = setOf("open", "pending", "answered", "on_hold") + val statusLabel = when (filters.statuses) { + activeStatuses -> "Aktiv" + emptySet() -> "Alle Status" + else -> metadata?.statuses?.firstOrNull { it.value in filters.statuses }?.label ?: "Status" + } + val assignedLabel = when (filters.assigned) { + "me" -> "Mir zugewiesen" + "unassigned" -> "Nicht zugewiesen" + else -> "Alle Zuständigkeiten" + } + val departmentLabel = metadata?.departments + ?.firstOrNull { it.id == filters.departmentId }?.name ?: "Alle Abteilungen" + val priorityLabel = metadata?.priorities + ?.firstOrNull { it.id == filters.priorityId }?.name ?: "Alle Prioritäten" + val tagLabel = metadata?.tags + ?.firstOrNull { it.id == filters.tagId }?.name ?: "Alle Tags" + val slaLabel = when (filters.sla) { + "overdue" -> "🔴 Überfällig" + "at_risk" -> "🟡 Bald fällig" + "on_time" -> "🟢 Im Zeitplan" + else -> "SLA: Alle" + } + + Surface(color = MaterialTheme.colorScheme.surface, shadowElevation = 1.dp) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = filters.query, + onValueChange = { onFiltersChanged(filters.copy(query = it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Ticketnummer, Betreff, Person oder Inhalt") }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + trailingIcon = { + TextButton(onClick = onSearch) { Text("Suchen") } + }, + ) + if (showSaveDialog) { + var viewName by remember { mutableStateOf("") } + var viewShared by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = { showSaveDialog = false }, + title = { Text("Ansicht speichern") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Speichert die aktuellen Filter.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField(value = viewName, onValueChange = { viewName = it }, label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = viewShared, onCheckedChange = { viewShared = it }) + Text("Für alle Mitarbeiter sichtbar") + } + } + }, + confirmButton = { + TextButton(enabled = viewName.isNotBlank(), onClick = { onSaveView(viewName.trim(), viewShared); showSaveDialog = false }) { Text("Speichern") } + }, + dismissButton = { TextButton(onClick = { showSaveDialog = false }) { Text("Abbrechen") } }, + ) + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + Box { + FilterChip( + selected = false, + onClick = { viewsExpanded = true }, + label = { Text("Ansichten") }, + shape = MaterialTheme.shapes.small, + ) + DropdownMenu(expanded = viewsExpanded, onDismissRequest = { viewsExpanded = false }) { + savedViews.forEach { view -> + DropdownMenuItem( + text = { Text(view.name + if (view.isShared) " ⇄" else "") }, + onClick = { onApplyView(view); viewsExpanded = false }, + trailingIcon = { + if (view.mine) { + IconButton(onClick = { onDeleteView(view.id); viewsExpanded = false }) { + Icon(Icons.Filled.Close, contentDescription = "Ansicht löschen") + } + } + }, + ) + } + if (savedViews.isNotEmpty()) HorizontalDivider() + DropdownMenuItem(text = { Text("Aktuelle Filter speichern …") }, onClick = { viewsExpanded = false; showSaveDialog = true }) + } + } + } + item { + Box { + FilterChip( + selected = filters.statuses != activeStatuses, + onClick = { statusExpanded = true }, + label = { Text(statusLabel) }, + shape = MaterialTheme.shapes.small, + ) + DropdownMenu(expanded = statusExpanded, onDismissRequest = { statusExpanded = false }) { + DropdownMenuItem( + text = { Text("Aktiv") }, + onClick = { + onFiltersChanged(filters.copy(statuses = activeStatuses)) + statusExpanded = false + onSearch() + }, + ) + DropdownMenuItem( + text = { Text("Alle Status") }, + onClick = { + onFiltersChanged(filters.copy(statuses = emptySet())) + statusExpanded = false + onSearch() + }, + ) + metadata?.statuses?.forEach { status -> + DropdownMenuItem( + text = { Text(status.label) }, + onClick = { + onFiltersChanged(filters.copy(statuses = setOf(status.value))) + statusExpanded = false + onSearch() + }, + ) + } + } + } + } + item { + Box { + FilterChip( + selected = filters.assigned.isNotEmpty(), + onClick = { assignedExpanded = true }, + label = { Text(assignedLabel) }, + shape = MaterialTheme.shapes.small, + ) + DropdownMenu(expanded = assignedExpanded, onDismissRequest = { assignedExpanded = false }) { + listOf( + "" to "Alle Zuständigkeiten", + "me" to "Mir zugewiesen", + "unassigned" to "Nicht zugewiesen", + ).forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + onFiltersChanged(filters.copy(assigned = value)) + assignedExpanded = false + onSearch() + }, + ) + } + } + } + } + item { + Box { + FilterChip( + selected = filters.departmentId != null, + onClick = { departmentExpanded = true }, + label = { Text(departmentLabel) }, + shape = MaterialTheme.shapes.small, + ) + DropdownMenu(expanded = departmentExpanded, onDismissRequest = { departmentExpanded = false }) { + DropdownMenuItem( + text = { Text("Alle Abteilungen") }, + onClick = { + onFiltersChanged(filters.copy(departmentId = null)) + departmentExpanded = false + onSearch() + }, + ) + metadata?.departments?.forEach { department -> + DropdownMenuItem( + text = { Text(department.name) }, + onClick = { + onFiltersChanged(filters.copy(departmentId = department.id)) + departmentExpanded = false + onSearch() + }, + ) + } + } + } + } + item { + Box { + FilterChip( + selected = filters.priorityId != null, + onClick = { priorityExpanded = true }, + label = { Text(priorityLabel) }, + shape = MaterialTheme.shapes.small, + ) + DropdownMenu(expanded = priorityExpanded, onDismissRequest = { priorityExpanded = false }) { + DropdownMenuItem( + text = { Text("Alle Prioritäten") }, + onClick = { + onFiltersChanged(filters.copy(priorityId = null)) + priorityExpanded = false + onSearch() + }, + ) + metadata?.priorities?.forEach { priority -> + DropdownMenuItem( + text = { Text(priority.name) }, + onClick = { + onFiltersChanged(filters.copy(priorityId = priority.id)) + priorityExpanded = false + onSearch() + }, + ) + } + } + } + } + if (metadata?.tags?.isNotEmpty() == true) { + item { + Box { + FilterChip( + selected = filters.tagId != null, + onClick = { tagExpanded = true }, + label = { Text(tagLabel) }, + shape = MaterialTheme.shapes.small, + ) + DropdownMenu(expanded = tagExpanded, onDismissRequest = { tagExpanded = false }) { + DropdownMenuItem( + text = { Text("Alle Tags") }, + onClick = { + onFiltersChanged(filters.copy(tagId = null)) + tagExpanded = false + onSearch() + }, + ) + metadata.tags.forEach { tag -> + DropdownMenuItem( + text = { Text(tag.name) }, + onClick = { + onFiltersChanged(filters.copy(tagId = tag.id)) + tagExpanded = false + onSearch() + }, + ) + } + } + } + } + } + item { + Box { + FilterChip( + selected = filters.sla.isNotEmpty(), + onClick = { slaExpanded = true }, + label = { Text(slaLabel) }, + shape = MaterialTheme.shapes.small, + ) + DropdownMenu(expanded = slaExpanded, onDismissRequest = { slaExpanded = false }) { + listOf( + "" to "SLA: Alle", + "overdue" to "🔴 Überfällig", + "at_risk" to "🟡 Bald fällig (< 4 Std.)", + "on_time" to "🟢 Im Zeitplan", + ).forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + onFiltersChanged(filters.copy(sla = value)) + slaExpanded = false + onSearch() + }, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun TicketCard( + ticket: TicketSummary, + metadata: Metadata?, + onTicket: (String) -> Unit, +) { + val priorityColor = parseHexColor(ticket.priority.color, MaterialTheme.colorScheme.outline) + Card( + onClick = { onTicket(ticket.number) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .background(priorityColor), + ) + Column( + modifier = Modifier + .padding(14.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + "#${ticket.number}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + StatusPill(ticket.status, metadata) + } + Text( + ticket.subject, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${ticket.requesterName} · ${ticket.department.name}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(priorityColor), + ) + Spacer(Modifier.width(5.dp)) + Text(ticket.priority.name, style = MaterialTheme.typography.labelSmall) + } + ticket.assignedStaff?.let { InitialsAvatarChip(it.name) } + if (ticket.isOverdue) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + "Überfällig", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + } +} + +@Composable +private fun InitialsAvatarChip(name: String) { + val initials = name.trim().split(" ").mapNotNull { it.firstOrNull()?.uppercaseChar() }.take(2).joinToString("") + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + initials, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.width(5.dp)) + Text(name, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +// ─── Dashboard ────────────────────────────────────────────────────────────── + +@Composable +private fun DashboardScreen( + state: DeskHubState, + onLoad: () -> Unit, + onSetStatus: (String, String) -> Unit, + onTicket: (String) -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + Scaffold( + topBar = { + TopAppBar( + title = { Text("Übersicht", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + ), + ) + }, + ) { padding -> + val stats = state.dashboard + when { + state.dashboardLoading && stats == null -> + Box(Modifier.padding(padding)) { LoadingState("Statistiken werden geladen …") } + state.dashboardError != null && stats == null -> + Box(Modifier.padding(padding)) { ErrorState(state.dashboardError, onLoad) } + stats != null -> LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Mein Status: derselbe Schnellwechsler wie im Web-Dashboard, direkt oben auf + // dem Startbildschirm statt erst über Verwaltung → Mein Status erreichbar. + state.availability?.let { availability -> + item { DashboardStatusRow(myStatus = availability.myStatus, onSetStatus = onSetStatus) } + } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile("Offen (mir)", stats.myOpen.toString(), Modifier.weight(1f)) + StatTile("Überfällig", stats.overdue.toString(), Modifier.weight(1f), highlight = stats.overdue > 0) + } + } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile("Nicht zugewiesen", stats.unassigned.toString(), Modifier.weight(1f)) + StatTile("Unbeantwortet", stats.unanswered.toString(), Modifier.weight(1f)) + } + } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile("Heute gelöst", stats.resolvedToday.toString(), Modifier.weight(1f)) + StatTile("SLA-Quote", stats.slaPercent?.let { "$it%" } ?: "–", Modifier.weight(1f)) + } + } + if (stats.recent.isNotEmpty()) { + item { + Text( + "Priorisiert (mir zugewiesen oder unbearbeitet)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + items(stats.recent, key = TicketSummary::id) { ticket -> + TicketCard(ticket, state.metadata, onTicket) + } + } + stats.departments?.let { departments -> + item { + Text( + "Abteilungen", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + items(departments) { department -> DepartmentRow(department) } + } + } + else -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + } + } +} + +@Composable +private fun DashboardStatusRow(myStatus: String, onSetStatus: (String, String) -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Mein Status", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AvailabilityStatusLabels.forEach { (value, meta) -> + val (label, emoji, color) = meta + FilterChip( + selected = myStatus == value, + onClick = { onSetStatus(value, "") }, + label = { Text("$emoji $label") }, + shape = MaterialTheme.shapes.small, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = color.copy(alpha = 0.18f), + selectedLabelColor = color, + ), + ) + } + } + } + } +} + +@Composable +private fun StatTile(label: String, value: String, modifier: Modifier = Modifier, highlight: Boolean = false) { + Card( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = if (highlight) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface, + ), + ) { + Column(Modifier.padding(16.dp)) { + Text( + value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = if (highlight) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface, + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = if (highlight) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DepartmentRow(department: de.deskhub.staff.domain.model.DepartmentStat) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Row( + modifier = Modifier + .padding(14.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(department.name, fontWeight = FontWeight.Medium) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "${department.open} offen", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (department.overdue > 0) { + Text( + "${department.overdue} überfällig", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} + +// ─── Admin: Verwaltung (Hub) ──────────────────────────────────────────────── + +@Composable +private fun AdminScreen(staff: Staff?, onNavigate: (String) -> Unit) { + val isFullAdmin = staff?.role == "admin" + fun allowed(permission: String) = staff?.permissions?.get(permission) == true + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Verwaltung", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + ), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val verwaltenVisible = allowed("manage_departments") || allowed("manage_priorities") || + allowed("manage_help_topics") || allowed("manage_kb") || allowed("manage_email_addresses") || allowed("manage_email_templates") + if (verwaltenVisible) item { AdminSectionLabel("Verwalten") } + if (allowed("manage_departments")) item { AdminMenuRow(Icons.Filled.Business, "Abteilungen", "Status, Leiter, Mitglieder, E-Mail & Signatur") { onNavigate("adminDepartments") } } + if (allowed("manage_priorities")) item { AdminMenuRow(Icons.Filled.Flag, "Prioritäten / SLA", "Reaktions- und Lösungszeiten") { onNavigate("adminPriorities") } } + if (allowed("view_reports")) item { AdminMenuRow(Icons.Filled.Assessment, "Berichte", "Frei konfigurierbare Auswertungen") { onNavigate("adminReports") } } + if (allowed("manage_priorities")) item { AdminMenuRow(Icons.Filled.Schedule, "SLA-Pläne", "Benannte Zeiten je Abteilung oder Hilfethema") { onNavigate("adminSlaPlans") } } + if (allowed("manage_help_topics")) item { AdminMenuRow(Icons.AutoMirrored.Filled.Help, "Hilfethemen", "Thema, Typ, Abteilung & Priorität") { onNavigate("adminHelpTopics") } } + if (allowed("manage_kb")) item { AdminMenuRow(Icons.AutoMirrored.Filled.MenuBook, "Wissensdatenbank", "Artikel und Kategorien verwalten") { onNavigate("adminKb") } } + if (allowed("manage_email_addresses")) item { AdminMenuRow(Icons.Filled.Email, "E-Mail-Adressen", "Adresse, Abteilung & Postfach-Abruf (IMAP)") { onNavigate("adminEmailAddresses") } } + if (allowed("manage_email_templates")) item { AdminMenuRow(Icons.Filled.Description, "E-Mail-Vorlagen", "Texte der automatischen E-Mails") { onNavigate("adminEmailTemplates") } } + + if (allowed("manage_organizations")) { + item { AdminSectionLabel("Kunden") } + item { AdminMenuRow(Icons.Filled.Apartment, "Organisationen", "Kunden zu Firmen bündeln") { onNavigate("adminOrganizations") } } + } + + val agentenVisible = allowed("manage_staff") || allowed("manage_teams") || allowed("manage_roles") + if (agentenVisible) item { AdminSectionLabel("Agenten") } + if (allowed("manage_staff")) item { AdminMenuRow(Icons.Filled.Person, "Mitarbeiter", "Konten, Rollen, Abteilungszugriff") { onNavigate("adminStaff") } } + if (allowed("manage_teams")) item { AdminMenuRow(Icons.Filled.Groups, "Teams", "Mitarbeiter-Gruppen") { onNavigate("adminTeams") } } + if (allowed("manage_roles")) item { AdminMenuRow(Icons.Filled.Lock, "Rollen", "Individuelle Berechtigungssets") { onNavigate("adminRoles") } } + + item { AdminSectionLabel("System") } + if (allowed("view_audit_log")) item { AdminMenuRow(Icons.Filled.History, "Admin-Protokoll", "Änderungen & fehlgeschlagene Mails") { onNavigate("adminAuditLog") } } + if (allowed("manage_escalations")) item { AdminMenuRow(Icons.AutoMirrored.Filled.Rule, "Eskalationsregeln", "Automatische Regeln für überfällige Tickets") { onNavigate("adminEscalation") } } + if (allowed("view_time_entries")) item { AdminMenuRow(Icons.Filled.AccessTime, "Zeiterfassung", "Erfasste Zeiten je Mitarbeiter einsehen") { onNavigate("adminTimeEntries") } } + if (allowed("manage_business_hours")) item { AdminMenuRow(Icons.Filled.Schedule, "Geschäftszeiten", "SLA-Zeitfenster & Feiertage") { onNavigate("adminBusinessHours") } } + // Ticket-Regeln (Automatisierung) wie im Web bewusst echten Admins vorbehalten + // (kein Co-Admin-Recht dafür, siehe api/staff/v1/ticket_rules.php). + if (isFullAdmin) item { AdminMenuRow(Icons.Filled.Bolt, "Automatisierung", "Ticket-Regeln, Auto-Antworten, automatisches Schließen") { onNavigate("adminTicketRules") } } + // Sperrliste hat (noch) kein eigenes Co-Admin-Recht – bleibt wie bisher echten Admins vorbehalten. + if (isFullAdmin) item { AdminMenuRow(Icons.Filled.Block, "Sperrliste", "E-Mail-Adressen oder ganze Mailanbieter sperren") { onNavigate("adminBanlist") } } + // Aufgaben sind für alle Mitarbeiter da – hier trotzdem gelistet, da wer die Verwaltung sieht, immer auch Mitarbeiter ist. + item { AdminMenuRow(Icons.AutoMirrored.Filled.Assignment, "Aufgaben", "To-dos für dich und dein Team") { onNavigate("adminTasks") } } + // Eigener Status ist für alle Mitarbeiter da (kein Admin-Recht nötig, wie im + // Web staff/availability.php) – hier trotzdem gelistet, aus demselben Grund wie Aufgaben oben. + item { AdminMenuRow(Icons.Filled.Circle, "Mein Status", "Verfügbarkeit setzen & Team-Übersicht") { onNavigate("adminAvailability") } } + } + } +} + +@Composable +private fun AdminSectionLabel(label: String) { + Text( + label.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 10.dp, bottom = 2.dp, start = 4.dp), + ) +} + +@Composable +private fun AdminMenuRow(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, subtitle: String, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer) + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun AdminTopBar(title: String, onBack: () -> Unit) { + TopAppBar( + title = { Text(title, fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + ) +} + +// ─── Interner Chat ─────────────────────────────────────────────────────────── + +// ─── Chat-Hub: interner Chat + Live-Chat unter einem Tab ──────────────────── +// Vorher zwei eigene Bottom-Nav-Einträge – bei 6 Tabs wurde die Leiste auf normalen +// Handy-Breiten zu voll (siehe Nutzer-Feedback). Beide Funktionen bleiben komplett +// erhalten, teilen sich jetzt nur die Navigation über einen Umschalter oben. + +@Composable +private fun ChatHubScreen( + state: DeskHubState, + onLoadContacts: () -> Unit, + onOpenContact: (de.deskhub.staff.domain.model.ChatContact) -> Unit, + onLoadLiveChats: () -> Unit, + onStopLiveChatPolling: () -> Unit, + onOpenLiveChat: (String) -> Unit, +) { + val liveChatAvailable = state.metadata?.liveChatEnabled == true + var showLiveChat by rememberSaveable { mutableStateOf(false) } + + DisposableEffect(Unit) { + onLoadContacts() + if (liveChatAvailable) onLoadLiveChats() + onDispose { if (liveChatAvailable) onStopLiveChatPolling() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Chat", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + ), + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + if (liveChatAvailable) { + val needsReply = state.liveChats?.count { it.needsReply } ?: 0 + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + SegmentedButton( + selected = !showLiveChat, + onClick = { showLiveChat = false }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text("Team") } + SegmentedButton( + selected = showLiveChat, + onClick = { showLiveChat = true }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { + BadgedBox(badge = { if (needsReply > 0) Badge { Text(needsReply.toString()) } }) { + Text("Kunden") + } + } + } + } + Box(Modifier.weight(1f).fillMaxWidth()) { + if (showLiveChat && liveChatAvailable) { + LiveChatListBody(state = state, onLoad = onLoadLiveChats, onOpenChat = onOpenLiveChat) + } else { + ChatContactsBody(state = state, onLoad = onLoadContacts, onOpenContact = onOpenContact) + } + } + } + } +} + +@Composable +private fun ChatContactsBody( + state: DeskHubState, + onLoad: () -> Unit, + onOpenContact: (de.deskhub.staff.domain.model.ChatContact) -> Unit, +) { + val contacts = state.chatContacts + when { + state.chatContactsLoading && contacts == null -> LoadingState("Kontakte werden geladen …") + state.chatContactsError != null && contacts == null -> ErrorState(state.chatContactsError, onLoad) + contacts == null -> EmptyState("Keine Daten verfügbar.") + contacts.isEmpty() -> EmptyState("Keine weiteren Mitarbeiter vorhanden.") + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(contacts, key = { it.id }) { contact -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenContact(contact) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(contact.name, fontWeight = FontWeight.Medium) + contact.lastMessageAt?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.75f)) + } + } + if (contact.unreadCount > 0) { + Badge { Text(contact.unreadCount.toString()) } + } + } + } + } + } + } +} + +@Composable +private fun ChatConversationScreen( + state: DeskHubState, + onBack: () -> Unit, + onSend: (Int, String) -> Unit, + onClearError: () -> Unit, +) { + val conversation = state.chatConversation + var draft by rememberSaveable { mutableStateOf("") } + val listState = rememberLazyListState() + + LaunchedEffect(conversation?.messages?.size) { + val count = conversation?.messages?.size ?: 0 + if (count > 0) listState.animateScrollToItem(count - 1) + } + + Scaffold( + topBar = { AdminTopBar(conversation?.with?.name ?: "Chat", onBack) }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + when { + state.chatConversationLoading && conversation == null -> Box(Modifier.weight(1f)) { LoadingState("Unterhaltung wird geladen …") } + state.chatConversationError != null && conversation == null -> Box(Modifier.weight(1f)) { ErrorState(state.chatConversationError) {} } + conversation == null -> Box(Modifier.weight(1f)) { EmptyState("Keine Daten verfügbar.") } + conversation.messages.isEmpty() -> Box(Modifier.weight(1f)) { EmptyState("Noch keine Nachrichten. Schreib die erste!") } + else -> LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(conversation.messages, key = { it.id }) { msg -> + val isOwn = msg.fromStaffId == state.staff?.id + Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOwn) Arrangement.End else Arrangement.Start) { + Surface( + color = if (isOwn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp).widthIn(max = 280.dp)) { + Text( + msg.message, + color = if (isOwn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + msg.createdAt, + style = MaterialTheme.typography.labelSmall, + color = (if (isOwn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant).copy(alpha = 0.7f), + ) + } + } + } + } + } + } + state.chatSendError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) } + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Nachricht …") }, + shape = MaterialTheme.shapes.medium, + ) + IconButton( + onClick = { + val withId = conversation?.with?.id + if (withId != null && draft.isNotBlank()) { + onClearError() + onSend(withId, draft.trim()) + draft = "" + } + }, + enabled = !state.chatSendLoading && draft.isNotBlank() && conversation != null, + ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden") } + } + } + } +} + +// ─── Live-Chat (Kunden-Widget) ────────────────────────────────────────────── +// Eigener Tab, getrennt vom internen Chat oben – Chats mit Kunden über das +// Live-Chat-Widget im Kundenportal, gespiegelt vom Web-Postfach staff/live_chat.php +// (gleiche Sperr-Logik: ein zweiter Mitarbeiter kann mitlesen, aber nicht schreiben). + +@Composable +private fun LiveChatListBody( + state: DeskHubState, + onLoad: () -> Unit, + onOpenChat: (String) -> Unit, +) { + val chats = state.liveChats + when { + state.liveChatsLoading && chats == null -> LoadingState("Chats werden geladen …") + state.liveChatsError != null && chats == null -> ErrorState(state.liveChatsError, onLoad) + chats == null -> EmptyState("Keine Daten verfügbar.") + chats.isEmpty() -> EmptyState("Aktuell keine offenen Live-Chats.") + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(chats, key = { it.number }) { chat -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenChat(chat.number) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (chat.needsReply) { + Box( + Modifier + .size(8.dp) + .background(Color(0xFFDC2626), shape = androidx.compose.foundation.shape.CircleShape), + ) + Spacer(Modifier.width(6.dp)) + } + Text(chat.name, fontWeight = FontWeight.Medium) + } + Text( + chat.preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + chat.updatedAt.takeLast(5), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun LiveChatConversationScreen( + state: DeskHubState, + onBack: () -> Unit, + onSend: (String, String) -> Unit, + onTyping: (String) -> Unit, + onClearError: () -> Unit, + onEnd: (String) -> Unit, +) { + val detail = state.liveChatDetail + val number = detail?.number + var draft by rememberSaveable { mutableStateOf("") } + var showEndConfirm by rememberSaveable { mutableStateOf(false) } + val listState = rememberLazyListState() + val lockedByOther = detail?.lockedByOtherName + val isClosed = detail?.status == "closed" + + LaunchedEffect(detail?.messages?.size) { + val count = detail?.messages?.size ?: 0 + if (count > 0) listState.animateScrollToItem(count - 1) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(detail?.requesterName ?: "Live-Chat", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + detail?.requesterEmail?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } + }, + actions = { + if (!isClosed && lockedByOther == null && number != null) { + TextButton(onClick = { showEndConfirm = true }) { + Text("Beenden", color = Color.White) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + actionIconContentColor = Color.White, + ), + ) + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + if (lockedByOther != null) { + Surface(color = Color(0xFFFFFBEB)) { + Text( + "🔒 Wird gerade von $lockedByOther bearbeitet – Sie sehen live mit, können hier aber nicht schreiben.", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF92400E), + ) + } + } + when { + state.liveChatDetailLoading && detail == null -> Box(Modifier.weight(1f)) { LoadingState("Chat wird geladen …") } + state.liveChatDetailError != null && detail == null -> Box(Modifier.weight(1f)) { ErrorState(state.liveChatDetailError) {} } + detail == null -> Box(Modifier.weight(1f)) { EmptyState("Keine Daten verfügbar.") } + detail.messages.isEmpty() -> Box(Modifier.weight(1f)) { EmptyState("Noch keine Nachrichten.") } + else -> LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(detail.messages, key = { it.id }) { msg -> + val isStaff = msg.authorType == "staff" + Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start) { + Column(horizontalAlignment = if (isStaff) Alignment.End else Alignment.Start) { + if (isStaff && msg.authorName != null) { + Text( + msg.authorName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 2.dp, end = 4.dp), + ) + } + if (msg.message.isNotBlank()) { + Surface( + color = if (isStaff) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Text( + msg.message, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp).widthIn(max = 280.dp), + color = if (isStaff) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + msg.attachments.forEach { att -> + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + modifier = Modifier.padding(top = 4.dp), + ) { + Text( + "📎 ${att.name}", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + msg.createdAt.takeLast(5), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + if (detail.customerTyping) { + item { + Text( + "Kunde tippt …", + style = MaterialTheme.typography.labelSmall, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + state.liveChatSendError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) } + when { + isClosed -> Text( + "Dieser Chat wurde beendet.", + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + lockedByOther != null -> Text( + "Nur Lesezugriff, solange $lockedByOther diesen Chat bearbeitet.", + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it; number?.let(onTyping) }, + modifier = Modifier.weight(1f), + placeholder = { Text("Nachricht …") }, + shape = MaterialTheme.shapes.medium, + ) + IconButton( + onClick = { + if (number != null && draft.isNotBlank()) { + onClearError() + onSend(number, draft.trim()) + draft = "" + } + }, + enabled = !state.liveChatSendLoading && draft.isNotBlank() && number != null, + ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden") } + } + } + } + } + + if (showEndConfirm) { + AlertDialog( + onDismissRequest = { showEndConfirm = false }, + title = { Text("Chat wirklich beenden?") }, + text = { Text("Er bleibt als Ticket erhalten, verschwindet aber aus den aktiven Live-Chats.") }, + confirmButton = { + TextButton(onClick = { showEndConfirm = false; number?.let(onEnd) }) { Text("Beenden") } + }, + dismissButton = { + TextButton(onClick = { showEndConfirm = false }) { Text("Abbrechen") } + }, + ) + } +} + +// ─── Admin: Wissensdatenbank ──────────────────────────────────────────────── + +@Composable +private fun AdminKbScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSaveArticle: (Int?, String, Int, String, Boolean, () -> Unit) -> Unit, + onDeleteArticle: (Int) -> Unit, + onCreateCategory: (String) -> Unit, + onDeleteCategory: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingArticle by rememberSaveable { mutableStateOf(null) } + var isCreatingArticle by rememberSaveable { mutableStateOf(false) } + var isAddingCategory by rememberSaveable { mutableStateOf(false) } + + val kb = state.kb + val showForm = isCreatingArticle || editingArticle != null + + Scaffold( + topBar = { AdminTopBar("Wissensdatenbank", onBack) }, + floatingActionButton = { + if (kb != null && !showForm) { + FloatingActionButton(onClick = { isCreatingArticle = true }) { + Icon(Icons.Filled.Add, contentDescription = "Neuer Artikel") + } + } + }, + ) { padding -> + when { + state.kbLoading && kb == null -> Box(Modifier.padding(padding)) { LoadingState("Wissensdatenbank wird geladen …") } + state.kbError != null && kb == null -> Box(Modifier.padding(padding)) { ErrorState(state.kbError, onLoad) } + kb == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val article = kb.articles.firstOrNull { it.id == editingArticle } + KbArticleForm( + modifier = Modifier.padding(padding), + article = article, + categories = kb.categories, + saving = state.kbMutationLoading, + error = state.kbMutationError, + onCancel = { editingArticle = null; isCreatingArticle = false; onClearError() }, + onSave = { title, categoryId, body, published -> + onSaveArticle(article?.id, title, categoryId, body, published) { + editingArticle = null + isCreatingArticle = false + } + }, + ) + } + else -> LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { Text("Kategorien", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) } + items(kb.categories) { category -> + val articleCount = kb.articles.count { it.categoryId == category.id } + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(category.name, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("$articleCount Artikel", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (articleCount == 0) { + TextButton(onClick = { onDeleteCategory(category.id) }) { Text("Löschen") } + } + } + } + } + } + item { + if (isAddingCategory) { + var newCategoryName by rememberSaveable { mutableStateOf("") } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = newCategoryName, + onValueChange = { newCategoryName = it }, + modifier = Modifier.weight(1f), + label = { Text("Neue Kategorie") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + TextButton(onClick = { + if (newCategoryName.isNotBlank()) { + onCreateCategory(newCategoryName.trim()) + newCategoryName = "" + isAddingCategory = false + } + }) { Text("Hinzufügen") } + } + } else { + OutlinedButton(onClick = { isAddingCategory = true }, modifier = Modifier.fillMaxWidth()) { + Text("+ Kategorie hinzufügen") + } + } + } + item { Spacer(Modifier.height(8.dp)); Text("Artikel", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) } + if (kb.articles.isEmpty()) { + item { EmptyState("Noch keine Artikel.") } + } + items(kb.articles) { article -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(article.title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Surface( + color = if (article.isPublished) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + if (article.isPublished) "Live" else "Entwurf", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + Text(article.categoryName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 8.dp)) { + TextButton(onClick = { editingArticle = article.id }) { Text("Bearbeiten") } + TextButton(onClick = { onDeleteArticle(article.id) }) { Text("Löschen") } + } + } + } + } + } + } + } +} + +@Composable +private fun KbArticleForm( + modifier: Modifier = Modifier, + article: KbArticle?, + categories: List, + saving: Boolean, + error: String?, + onCancel: () -> Unit, + onSave: (String, Int, String, Boolean) -> Unit, +) { + var title by rememberSaveable(article?.id) { mutableStateOf(article?.title ?: "") } + var body by rememberSaveable(article?.id) { mutableStateOf(article?.body ?: "") } + var isPublished by rememberSaveable(article?.id) { mutableStateOf(article?.isPublished ?: true) } + var categoryId by rememberSaveable(article?.id) { mutableStateOf(article?.categoryId ?: categories.firstOrNull()?.id ?: 0) } + var categoryExpanded by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (article == null) "Neuer Artikel" else "Artikel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Titel") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + SelectionField( + label = "Kategorie", + value = categories.firstOrNull { it.id == categoryId }?.name ?: "–", + expanded = categoryExpanded, + onExpand = { categoryExpanded = true }, + onDismiss = { categoryExpanded = false }, + ) { + categories.forEach { category -> + DropdownMenuItem(text = { Text(category.name) }, onClick = { categoryId = category.id; categoryExpanded = false }) + } + } + OutlinedTextField( + value = body, + onValueChange = { body = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Inhalt") }, + shape = MaterialTheme.shapes.medium, + minLines = 8, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isPublished, onCheckedChange = { isPublished = it }) + Text("Veröffentlicht") + } + error?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onSave(title.trim(), categoryId, body.trim(), isPublished) }, + shape = MaterialTheme.shapes.medium, + enabled = !saving && title.isNotBlank() && body.isNotBlank() && categoryId != 0, + ) { + Text(if (saving) "Wird gespeichert …" else "Speichern") + } + OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } +} + +// ─── Admin: Zeiterfassung ──────────────────────────────────────────────────── + +@Composable +private fun AdminTimeEntriesScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: (Int?, String, String) -> Unit, +) { + LaunchedEffect(Unit) { onLoad(state.timeEntriesStaffId, state.timeEntriesFrom, state.timeEntriesTo) } + var from by rememberSaveable { mutableStateOf(state.timeEntriesFrom) } + var to by rememberSaveable { mutableStateOf(state.timeEntriesTo) } + var staffId by rememberSaveable { mutableStateOf(state.timeEntriesStaffId) } + var staffExpanded by remember { mutableStateOf(false) } + val report = state.timeEntries + + Scaffold(topBar = { AdminTopBar("Zeiterfassung", onBack) }) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = from, + onValueChange = { from = it }, + modifier = Modifier.weight(1f), + label = { Text("Von (JJJJ-MM-TT)") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = to, + onValueChange = { to = it }, + modifier = Modifier.weight(1f), + label = { Text("Bis (JJJJ-MM-TT)") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + } + SelectionField( + label = "Mitarbeiter", + value = report?.staffOptions?.firstOrNull { it.id == staffId }?.name ?: "Alle", + expanded = staffExpanded, + onExpand = { staffExpanded = true }, + onDismiss = { staffExpanded = false }, + ) { + DropdownMenuItem(text = { Text("Alle") }, onClick = { staffId = null; staffExpanded = false }) + report?.staffOptions.orEmpty().forEach { option -> + DropdownMenuItem(text = { Text(option.name) }, onClick = { staffId = option.id; staffExpanded = false }) + } + } + Button( + onClick = { onLoad(staffId, from, to) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) { Text("Filtern") } + } + when { + state.timeEntriesLoading && report == null -> LoadingState("Zeiten werden geladen …") + state.timeEntriesError != null && report == null -> ErrorState(state.timeEntriesError) { onLoad(staffId, from, to) } + report != null -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (report.totalsByStaff.isNotEmpty()) { + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile("Gesamt", formatMinutes(report.totalMinutes), Modifier.weight(1f)) + StatTile( + report.totalsByStaff.first().staffName, + formatMinutes(report.totalsByStaff.first().minutes), + Modifier.weight(1f), + ) + } + } + if (report.totalsByStaff.size > 1) { + items(report.totalsByStaff.drop(1)) { total -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(total.staffName) + Text(formatMinutes(total.minutes), fontWeight = FontWeight.SemiBold) + } + } + } + } + } + if (report.entries.isEmpty()) { + item { EmptyState("Keine Zeiteinträge im gewählten Zeitraum.") } + } + items(report.entries) { entry -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("#${entry.ticketNumber} · ${entry.subject}", modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(formatMinutes(entry.minutes), fontWeight = FontWeight.SemiBold) + } + Text( + "${entry.staffName} · ${entry.loggedAt}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + entry.note?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + } + } + } +} + +private fun formatMinutes(minutes: Int): String = "${minutes / 60}h ${minutes % 60}min" + +// ─── Admin: Eskalationsregeln ──────────────────────────────────────────────── + +private val EscalationTriggerLabels = linkedMapOf( + "no_reply" to "Keine Mitarbeiter-Antwort seit X Stunden", + "no_update" to "Kein Update seit X Stunden", + "overdue" to "Ticket ist überfällig (SLA überschritten)", + "open_too_long" to "Ticket offen seit X Stunden", +) + +private val EscalationStatusLabels = linkedMapOf( + "open" to "Offen", + "pending" to "Wartet auf Kunde", + "on_hold" to "Pausiert", +) + +@Composable +private fun AdminEscalationScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, String, Boolean, String, Int, String?, Int?, Int?, Int?, Int?, String?, Boolean, Boolean, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onToggle: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingRuleId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.escalation + val showForm = isCreating || editingRuleId != null + + Scaffold( + topBar = { AdminTopBar("Eskalationsregeln", onBack) }, + floatingActionButton = { + if (data != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { + Icon(Icons.Filled.Add, contentDescription = "Neue Regel") + } + } + }, + ) { padding -> + when { + state.escalationLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Regeln werden geladen …") } + state.escalationError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.escalationError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val rule = data.rules.firstOrNull { it.id == editingRuleId } + EscalationRuleForm( + modifier = Modifier.padding(padding), + rule = rule, + priorities = data.priorities, + staffOptions = data.staffOptions, + departments = data.departments, + saving = state.escalationMutationLoading, + error = state.escalationMutationError, + onCancel = { editingRuleId = null; isCreating = false; onClearError() }, + onSave = { name, isActive, triggerType, triggerHours, condStatus, condPrio, condDept, actPrio, actStaff, actStatus, notAdmin, notStaff -> + onSave(rule?.id, name, isActive, triggerType, triggerHours, condStatus, condPrio, condDept, actPrio, actStaff, actStatus, notAdmin, notStaff) { + editingRuleId = null + isCreating = false + } + }, + ) + } + data.rules.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Eskalationsregeln. Mit + eine neue Regel anlegen.") } + else -> LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(data.rules) { rule -> EscalationRuleRow(rule, onEdit = { editingRuleId = rule.id }, onDelete = { onDelete(rule.id) }, onToggle = { onToggle(rule.id) }) } + } + } + } +} + +@Composable +private fun EscalationRuleRow(rule: EscalationRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Surface( + color = if (rule.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + if (rule.isActive) "Aktiv" else "Inaktiv", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + Text( + EscalationTriggerLabels[rule.triggerType] ?: rule.triggerType, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val actions = buildList { + rule.actionSetPriorityName?.let { add("↑ Priorität: $it") } + rule.actionAssignStaffName?.let { add("→ $it") } + rule.actionSetStatus?.let { add("⚑ ${EscalationStatusLabels[it] ?: it}") } + if (rule.actionNotifyAdmin) add("📧 Admin") + if (rule.actionNotifyStaff) add("📧 Mitarbeiter") + } + Text( + if (actions.isEmpty()) "Nur benachrichtigen" else actions.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + ) + Text( + "${rule.appliedCount}× angewendet", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = onEdit) { Text("Bearbeiten") } + TextButton(onClick = onToggle) { Text(if (rule.isActive) "Deaktivieren" else "Aktivieren") } + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +@Composable +private fun EscalationRuleForm( + modifier: Modifier = Modifier, + rule: EscalationRule?, + priorities: List, + staffOptions: List, + departments: List, + saving: Boolean, + error: String?, + onCancel: () -> Unit, + onSave: (String, Boolean, String, Int, String?, Int?, Int?, Int?, Int?, String?, Boolean, Boolean) -> Unit, +) { + var name by rememberSaveable(rule?.id) { mutableStateOf(rule?.name ?: "") } + var isActive by rememberSaveable(rule?.id) { mutableStateOf(rule?.isActive ?: true) } + var triggerType by rememberSaveable(rule?.id) { mutableStateOf(rule?.triggerType ?: "no_reply") } + var triggerHours by rememberSaveable(rule?.id) { mutableStateOf((rule?.triggerHours ?: 24).toString()) } + var conditionStatus by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionStatus ?: "") } + var conditionPriorityId by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionPriorityId) } + var conditionDeptId by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionDeptId) } + var actionSetPriorityId by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionSetPriorityId) } + var actionAssignStaffId by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionAssignStaffId) } + var actionSetStatus by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionSetStatus) } + var notifyAdmin by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionNotifyAdmin ?: true) } + var notifyStaff by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionNotifyStaff ?: false) } + + var triggerExpanded by remember { mutableStateOf(false) } + var condPrioExpanded by remember { mutableStateOf(false) } + var condDeptExpanded by remember { mutableStateOf(false) } + var actPrioExpanded by remember { mutableStateOf(false) } + var actStaffExpanded by remember { mutableStateOf(false) } + var actStatusExpanded by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (rule == null) "Neue Eskalationsregel" else "Regel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Name") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isActive, onCheckedChange = { isActive = it }) + Text("Aktiv") + } + + Text("Auslöser", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + SelectionField( + label = "Trigger", + value = EscalationTriggerLabels[triggerType] ?: triggerType, + expanded = triggerExpanded, + onExpand = { triggerExpanded = true }, + onDismiss = { triggerExpanded = false }, + ) { + EscalationTriggerLabels.forEach { (value, label) -> + DropdownMenuItem(text = { Text(label) }, onClick = { triggerType = value; triggerExpanded = false }) + } + } + if (triggerType != "overdue") { + OutlinedTextField( + value = triggerHours, + onValueChange = { triggerHours = it.filter(Char::isDigit) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Stunden") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + + Text("Bedingungen (optional – leer = alle Tickets)", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = conditionStatus, + onValueChange = { conditionStatus = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Nur Status (kommagetrennt, z.B. open,pending)") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + SelectionField( + label = "Nur Priorität", + value = priorities.firstOrNull { it.id == conditionPriorityId }?.name ?: "Alle", + expanded = condPrioExpanded, + onExpand = { condPrioExpanded = true }, + onDismiss = { condPrioExpanded = false }, + ) { + DropdownMenuItem(text = { Text("Alle") }, onClick = { conditionPriorityId = null; condPrioExpanded = false }) + priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { conditionPriorityId = p.id; condPrioExpanded = false }) } + } + SelectionField( + label = "Nur Abteilung", + value = departments.firstOrNull { it.id == conditionDeptId }?.name ?: "Alle", + expanded = condDeptExpanded, + onExpand = { condDeptExpanded = true }, + onDismiss = { condDeptExpanded = false }, + ) { + DropdownMenuItem(text = { Text("Alle") }, onClick = { conditionDeptId = null; condDeptExpanded = false }) + departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { conditionDeptId = d.id; condDeptExpanded = false }) } + } + + Text("Aktionen", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + SelectionField( + label = "Priorität setzen", + value = priorities.firstOrNull { it.id == actionSetPriorityId }?.name ?: "– keine Änderung –", + expanded = actPrioExpanded, + onExpand = { actPrioExpanded = true }, + onDismiss = { actPrioExpanded = false }, + ) { + DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionSetPriorityId = null; actPrioExpanded = false }) + priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { actionSetPriorityId = p.id; actPrioExpanded = false }) } + } + SelectionField( + label = "Mitarbeiter zuweisen", + value = staffOptions.firstOrNull { it.id == actionAssignStaffId }?.name ?: "– keine Änderung –", + expanded = actStaffExpanded, + onExpand = { actStaffExpanded = true }, + onDismiss = { actStaffExpanded = false }, + ) { + DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionAssignStaffId = null; actStaffExpanded = false }) + staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { actionAssignStaffId = s.id; actStaffExpanded = false }) } + } + SelectionField( + label = "Status setzen", + value = EscalationStatusLabels[actionSetStatus] ?: "– keine Änderung –", + expanded = actStatusExpanded, + onExpand = { actStatusExpanded = true }, + onDismiss = { actStatusExpanded = false }, + ) { + DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionSetStatus = null; actStatusExpanded = false }) + EscalationStatusLabels.forEach { (value, label) -> + DropdownMenuItem(text = { Text(label) }, onClick = { actionSetStatus = value; actStatusExpanded = false }) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = notifyAdmin, onCheckedChange = { notifyAdmin = it }) + Text("Admin benachrichtigen") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = notifyStaff, onCheckedChange = { notifyStaff = it }) + Text("Zugewiesenen Mitarbeiter benachrichtigen") + } + + error?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + onSave( + name.trim(), isActive, triggerType, triggerHours.toIntOrNull() ?: 24, + conditionStatus.trim().ifBlank { null }, conditionPriorityId, conditionDeptId, + actionSetPriorityId, actionAssignStaffId, actionSetStatus, notifyAdmin, notifyStaff, + ) + }, + shape = MaterialTheme.shapes.medium, + enabled = !saving && name.isNotBlank(), + ) { Text(if (saving) "Wird gespeichert …" else "Speichern") } + OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } +} + +// ─── Mein Status (Verfügbarkeit) ──────────────────────────────────────────── +// Bewusst NICHT admin-gated (siehe AdminScreen oben) – jeder Mitarbeiter darf seinen +// eigenen Status setzen, genau wie unter staff/availability.php im Web. + +private val AvailabilityStatusLabels = mapOf( + "available" to Triple("Verfügbar", "🟢", Color(0xFF22C55E)), + "busy" to Triple("Beschäftigt", "🟡", Color(0xFFF59E0B)), + "break" to Triple("Pause", "🟣", Color(0xFF6366F1)), + "offline" to Triple("Offline", "⚫", Color(0xFF94A3B8)), +) + +@Composable +private fun AdminAvailabilityScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSetStatus: (String, String) -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + val data = state.availability + var selectedStatus by rememberSaveable(data?.myStatus) { mutableStateOf(data?.myStatus ?: "available") } + var note by rememberSaveable(data?.myNote) { mutableStateOf(data?.myNote ?: "") } + + Scaffold( + topBar = { AdminTopBar("Mein Status", onBack) }, + ) { padding -> + when { + state.availabilityLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Status wird geladen …") } + state.availabilityError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.availabilityError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + else -> LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Mein Status", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AvailabilityStatusLabels.forEach { (value, meta) -> + val (label, emoji, color) = meta + FilterChip( + selected = selectedStatus == value, + onClick = { selectedStatus = value }, + label = { Text("$emoji $label") }, + shape = MaterialTheme.shapes.small, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = color.copy(alpha = 0.18f), + selectedLabelColor = color, + ), + ) + } + } + OutlinedTextField( + value = note, + onValueChange = { note = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Notiz (optional)") }, + placeholder = { Text("z.B. „In Besprechung bis 14:00“") }, + shape = MaterialTheme.shapes.medium, + singleLine = true, + ) + state.availabilityError?.let { ErrorText(it) } + Button( + onClick = { onSetStatus(selectedStatus, note.trim()) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + enabled = !state.availabilitySaving, + ) { Text(if (state.availabilitySaving) "Wird gespeichert …" else "Speichern") } + } + } + } + item { AdminSectionLabel("Team-Übersicht") } + items(data.team, key = { it.id }) { member -> + val meta = AvailabilityStatusLabels[member.status] ?: Triple("Unbekannt", "⚫", Color(0xFF94A3B8)) + val (label, emoji, color) = meta + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(member.name, fontWeight = FontWeight.Medium) + if (member.note.isNotBlank()) { + Text(member.note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Surface(color = color.copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) { + Text( + "$emoji $label", + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } + } + } +} + +// ─── Automatisierung (Ticket-Regeln + Auto-Close) ─────────────────────────── + +@Composable +private fun AdminTicketRulesScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, String, String, Boolean, Int, List, List, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onToggle: (Int) -> Unit, + onClearError: () -> Unit, + onSaveAutoClose: (Boolean, Int) -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingRuleId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.ticketRules + val showForm = isCreating || editingRuleId != null + + Scaffold( + topBar = { AdminTopBar("Automatisierung", onBack) }, + floatingActionButton = { + if (data != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { + Icon(Icons.Filled.Add, contentDescription = "Neue Regel") + } + } + }, + ) { padding -> + when { + state.ticketRulesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Regeln werden geladen …") } + state.ticketRulesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.ticketRulesError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val rule = data.rules.firstOrNull { it.id == editingRuleId } + TicketRuleForm( + modifier = Modifier.padding(padding), + rule = rule, + data = data, + saving = state.ticketRulesMutationLoading, + error = state.ticketRulesMutationError, + onCancel = { editingRuleId = null; isCreating = false; onClearError() }, + onSave = { name, matchType, isActive, sortOrder, conditions, actions -> + onSave(rule?.id, name, matchType, isActive, sortOrder, conditions, actions) { + editingRuleId = null + isCreating = false + } + }, + ) + } + else -> LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + AutoCloseCard( + enabled = data.autoCloseEnabled, + days = data.autoCloseDays, + onSave = onSaveAutoClose, + ) + } + item { AdminSectionLabel("Ticket-Regeln") } + if (data.rules.isEmpty()) { + item { EmptyState("Noch keine Ticket-Regeln. Mit + eine neue Regel anlegen.") } + } else { + items(data.rules, key = { it.id }) { rule -> + TicketRuleRow(rule, onEdit = { editingRuleId = rule.id }, onDelete = { onDelete(rule.id) }, onToggle = { onToggle(rule.id) }) + } + } + } + } + } +} + +@Composable +private fun AutoCloseCard(enabled: Boolean, days: Int, onSave: (Boolean, Int) -> Unit) { + var localEnabled by rememberSaveable(enabled) { mutableStateOf(enabled) } + var localDays by rememberSaveable(days) { mutableStateOf(days.toString()) } + val changed = localEnabled != enabled || localDays != days.toString() + + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier.fillMaxWidth().clickable { localEnabled = !localEnabled }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Automatisches Schließen", fontWeight = FontWeight.SemiBold) + Text( + "Schließt Tickets, auf die der Kunde nach einer Antwort nicht mehr reagiert.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = localEnabled, onCheckedChange = { localEnabled = it }) + } + if (localEnabled) { + OutlinedTextField( + value = localDays, + onValueChange = { v -> if (v.all(Char::isDigit)) localDays = v }, + label = { Text("Tage ohne Reaktion") }, + modifier = Modifier.width(160.dp), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + if (changed) { + Button( + onClick = { onSave(localEnabled, localDays.toIntOrNull()?.coerceIn(1, 90) ?: 7) }, + shape = MaterialTheme.shapes.medium, + ) { Text("Speichern") } + } + } + } +} + +@Composable +private fun TicketRuleRow(rule: TicketRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Surface( + color = if (rule.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + if (rule.isActive) "Aktiv" else "Inaktiv", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + Text( + "${rule.conditions.size} Bedingung(en) (${if (rule.matchType == "any") "eine trifft" else "alle treffen"})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + rule.actions.joinToString(", ") { it.valueLabel ?: it.type }, + style = MaterialTheme.typography.bodySmall, + ) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = onEdit) { Text("Bearbeiten") } + TextButton(onClick = onToggle) { Text(if (rule.isActive) "Deaktivieren" else "Aktivieren") } + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +@Composable +private fun TicketRuleForm( + modifier: Modifier = Modifier, + rule: TicketRule?, + data: TicketRulesData, + saving: Boolean, + error: String?, + onCancel: () -> Unit, + onSave: (String, String, Boolean, Int, List, List) -> Unit, +) { + var name by rememberSaveable(rule?.id) { mutableStateOf(rule?.name ?: "") } + var matchType by rememberSaveable(rule?.id) { mutableStateOf(rule?.matchType ?: "all") } + var isActive by rememberSaveable(rule?.id) { mutableStateOf(rule?.isActive ?: true) } + var sortOrder by rememberSaveable(rule?.id) { mutableStateOf((rule?.sortOrder ?: 0).toString()) } + val conditions = remember(rule?.id) { + mutableStateListOf(*(rule?.conditions?.toTypedArray() ?: arrayOf(TicketRuleCondition("subject", "contains", "")))) + } + val actions = remember(rule?.id) { + mutableStateListOf(*(rule?.actions?.toTypedArray() ?: arrayOf(TicketRuleAction("set_priority", "", null)))) + } + + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (rule == null) "Neue Ticket-Regel" else "Ticket-Regel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton(selected = matchType == "all", onClick = { matchType = "all" }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { Text("Alle Bedingungen") } + SegmentedButton(selected = matchType == "any", onClick = { matchType = "any" }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { Text("Eine Bedingung") } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = isActive, onCheckedChange = { isActive = it }) + Spacer(Modifier.width(8.dp)) + Text("Aktiv") + } + + Text("Bedingungen", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + conditions.forEachIndexed { index, cond -> + RuleConditionRow( + condition = cond, + fieldOptions = data.fieldOptions, + operatorOptions = data.operatorOptions, + onChange = { conditions[index] = it }, + onRemove = { if (conditions.size > 1) conditions.removeAt(index) }, + ) + } + OutlinedButton(onClick = { conditions.add(TicketRuleCondition("subject", "contains", "")) }, shape = MaterialTheme.shapes.medium) { + Text("+ Bedingung hinzufügen") + } + + Text("Aktionen", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + actions.forEachIndexed { index, act -> + RuleActionRow( + action = act, + data = data, + onChange = { actions[index] = it }, + onRemove = { if (actions.size > 1) actions.removeAt(index) }, + ) + } + OutlinedButton(onClick = { actions.add(TicketRuleAction("set_priority", "", null)) }, shape = MaterialTheme.shapes.medium) { + Text("+ Aktion hinzufügen") + } + + error?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onSave(name.trim(), matchType, isActive, sortOrder.toIntOrNull() ?: 0, conditions.toList(), actions.toList()) }, + shape = MaterialTheme.shapes.medium, + enabled = !saving && name.isNotBlank(), + ) { Text(if (saving) "Wird gespeichert …" else "Speichern") } + OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } +} + +@Composable +private fun RuleConditionRow( + condition: TicketRuleCondition, + fieldOptions: List, + operatorOptions: List, + onChange: (TicketRuleCondition) -> Unit, + onRemove: () -> Unit, +) { + var fieldExpanded by remember { mutableStateOf(false) } + var operatorExpanded by remember { mutableStateOf(false) } + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + SelectionField( + label = "Feld", + value = fieldOptions.firstOrNull { it.value == condition.field }?.label ?: condition.field, + expanded = fieldExpanded, + onExpand = { fieldExpanded = true }, + onDismiss = { fieldExpanded = false }, + ) { + fieldOptions.forEach { opt -> + DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(condition.copy(field = opt.value)); fieldExpanded = false }) + } + } + SelectionField( + label = "Operator", + value = operatorOptions.firstOrNull { it.value == condition.operator }?.label ?: condition.operator, + expanded = operatorExpanded, + onExpand = { operatorExpanded = true }, + onDismiss = { operatorExpanded = false }, + ) { + operatorOptions.forEach { opt -> + DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(condition.copy(operator = opt.value)); operatorExpanded = false }) + } + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = condition.value, + onValueChange = { onChange(condition.copy(value = it)) }, + label = { Text("Wert") }, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.medium, + singleLine = true, + ) + IconButton(onClick = onRemove) { Icon(Icons.Filled.Block, contentDescription = "Entfernen") } + } + } + } +} + +@Composable +private fun RuleActionRow( + action: TicketRuleAction, + data: TicketRulesData, + onChange: (TicketRuleAction) -> Unit, + onRemove: () -> Unit, +) { + var typeExpanded by remember { mutableStateOf(false) } + var valueExpanded by remember { mutableStateOf(false) } + val valueOptions: List = when (action.type) { + "set_department" -> data.departments + "set_priority" -> data.priorities + "set_help_topic" -> data.helpTopics + "assign_staff" -> data.staffOptions + "auto_reply" -> data.cannedResponses + else -> emptyList() + } + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.weight(1f)) { + SelectionField( + label = "Aktion", + value = data.actionOptions.firstOrNull { it.value == action.type }?.label ?: action.type, + expanded = typeExpanded, + onExpand = { typeExpanded = true }, + onDismiss = { typeExpanded = false }, + ) { + data.actionOptions.forEach { opt -> + DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(TicketRuleAction(opt.value, "", null)); typeExpanded = false }) + } + } + } + IconButton(onClick = onRemove) { Icon(Icons.Filled.Block, contentDescription = "Entfernen") } + } + when (action.type) { + "reject" -> Text("Keine weitere Einstellung nötig.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + "add_tag" -> OutlinedTextField( + value = action.value, + onValueChange = { onChange(action.copy(value = it)) }, + label = { Text("Tag-Name") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + singleLine = true, + ) + else -> SelectionField( + label = "Wert", + value = valueOptions.firstOrNull { it.id.toString() == action.value }?.name ?: "– wählen –", + expanded = valueExpanded, + onExpand = { valueExpanded = true }, + onDismiss = { valueExpanded = false }, + ) { + valueOptions.forEach { opt -> + DropdownMenuItem(text = { Text(opt.name) }, onClick = { onChange(action.copy(value = opt.id.toString(), valueLabel = opt.name)); valueExpanded = false }) + } + } + } + } + } +} + +// ─── Admin: Sperrliste ─────────────────────────────────────────────────────── + +@Composable +private fun AdminBanlistScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onCreate: (String, String, String?, () -> Unit) -> Unit, + onBulkAdd: (String, String?, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, + onClearBulkResult: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var showForm by rememberSaveable { mutableStateOf(false) } + val entries = state.banlist + + Scaffold( + topBar = { AdminTopBar("Sperrliste", onBack) }, + floatingActionButton = { + if (entries != null && !showForm) { + FloatingActionButton(onClick = { showForm = true }) { + Icon(Icons.Filled.Add, contentDescription = "Sperren") + } + } + }, + ) { padding -> + when { + state.banlistLoading && entries == null -> Box(Modifier.padding(padding)) { LoadingState("Sperrliste wird geladen …") } + state.banlistError != null && entries == null -> Box(Modifier.padding(padding)) { ErrorState(state.banlistError, onLoad) } + entries == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> BanlistForm( + modifier = Modifier.padding(padding), + saving = state.banlistMutationLoading, + error = state.banlistMutationError, + bulkResult = state.banlistLastBulkResult, + onCancel = { showForm = false; onClearError(); onClearBulkResult() }, + onCreate = { matchType, value, reason -> onCreate(matchType, value, reason) { showForm = false } }, + onBulkAdd = { values, reason -> onBulkAdd(values, reason) { } }, + ) + entries.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Sperrliste ist leer. Mit + eine E-Mail-Adresse oder Domain sperren.") } + else -> LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(entries) { entry -> BanEntryRow(entry, onDelete = { onDelete(entry.id) }) } + } + } + } +} + +@Composable +private fun BanEntryRow(entry: BanEntry, onDelete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + color = if (entry.matchType == "domain") Color(0xFFF3E8FF) else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + if (entry.matchType == "domain") "Domain" else "E-Mail", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = if (entry.matchType == "domain") Color(0xFF7E22CE) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + Text(entry.value, fontWeight = FontWeight.SemiBold) + } + TextButton(onClick = onDelete) { Text("Entsperren") } + } + entry.reason?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp)) + } + Text( + buildString { + append(entry.createdAt) + entry.addedByName?.let { append(" · $it") } + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun BanlistForm( + modifier: Modifier = Modifier, + saving: Boolean, + error: String?, + bulkResult: Pair?, + onCancel: () -> Unit, + onCreate: (String, String, String?) -> Unit, + onBulkAdd: (String, String?) -> Unit, +) { + var bulkMode by rememberSaveable { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = !bulkMode, + onClick = { bulkMode = false }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { Text("Einzeln") } + SegmentedButton( + selected = bulkMode, + onClick = { bulkMode = true }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { Text("Mehrere") } + } + + if (!bulkMode) { + var matchType by rememberSaveable { mutableStateOf("email") } + var value by rememberSaveable { mutableStateOf("") } + var reason by rememberSaveable { mutableStateOf("") } + var typeExpanded by remember { mutableStateOf(false) } + + SelectionField( + label = "Typ", + value = if (matchType == "domain") "Domain (ganzer Mailanbieter)" else "E-Mail-Adresse", + expanded = typeExpanded, + onExpand = { typeExpanded = true }, + onDismiss = { typeExpanded = false }, + ) { + DropdownMenuItem(text = { Text("E-Mail-Adresse") }, onClick = { matchType = "email"; typeExpanded = false }) + DropdownMenuItem(text = { Text("Domain (ganzer Mailanbieter)") }, onClick = { matchType = "domain"; typeExpanded = false }) + } + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(if (matchType == "domain") "Domain, z.B. spam.de" else "E-Mail, z.B. spam@beispiel.de") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = reason, + onValueChange = { reason = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Grund (optional)") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + error?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onCreate(matchType, value.trim(), reason.trim().ifBlank { null }) }, + shape = MaterialTheme.shapes.medium, + enabled = !saving && value.isNotBlank(), + ) { Text(if (saving) "Wird gesperrt …" else "Sperren") } + OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } else { + var values by rememberSaveable { mutableStateOf("") } + var reason by rememberSaveable { mutableStateOf("") } + + Text( + "Eine E-Mail oder Domain pro Zeile. Typ wird automatisch erkannt.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = values, + onValueChange = { values = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("E-Mails / Domains") }, + shape = MaterialTheme.shapes.medium, + minLines = 6, + ) + OutlinedTextField( + value = reason, + onValueChange = { reason = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Grund für alle (optional)") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + bulkResult?.let { (added, skipped) -> + Text( + "$added gesperrt, $skipped übersprungen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + error?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onBulkAdd(values, reason.trim().ifBlank { null }) }, + shape = MaterialTheme.shapes.medium, + enabled = !saving && values.isNotBlank(), + ) { Text(if (saving) "Wird gesperrt …" else "Alle sperren") } + OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Fertig") } + } + } + } +} + +// ─── Admin: Abteilungen ────────────────────────────────────────────────────── + +@Composable +private fun AdminDepartmentsScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, DepartmentInput, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, + onUploadImage: (ByteArray, String, String, (String) -> Unit) -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val items = state.departments2 + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("Abteilungen", onBack) }, + floatingActionButton = { + if (items != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Abteilung") } + } + }, + ) { padding -> + when { + state.departments2Loading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Abteilungen werden geladen …") } + state.departments2Error != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.departments2Error, onLoad) } + items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val dept = items.firstOrNull { it.id == editingId } + var tab by rememberSaveable { mutableStateOf(0) } + var name by rememberSaveable(dept?.id) { mutableStateOf(dept?.name ?: "") } + var email by rememberSaveable(dept?.id) { mutableStateOf(dept?.email ?: "") } + var isDefault by rememberSaveable(dept?.id) { mutableStateOf(dept?.isDefault ?: false) } + var isActive by rememberSaveable(dept?.id) { mutableStateOf(dept?.isActive ?: true) } + var managerId by rememberSaveable(dept?.id) { mutableStateOf(dept?.managerId) } + var notes by rememberSaveable(dept?.id) { mutableStateOf(dept?.notes ?: "") } + var signature by rememberSaveable(dept?.id) { mutableStateOf(dept?.signature ?: "") } + var emailExpanded by remember { mutableStateOf(false) } + var managerExpanded by remember { mutableStateOf(false) } + var slaPlanId by rememberSaveable(dept?.id) { mutableStateOf(dept?.slaPlanId) } + var slaExpanded by remember { mutableStateOf(false) } + Column(Modifier.padding(padding).fillMaxSize()) { + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Abteilung") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Mitglieder" + (dept?.let { " (${it.members.size})" } ?: "")) }) + } + Column( + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (dept == null) "Neue Abteilung" else "Abteilung aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (tab == 0) { + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Text("Status", style = MaterialTheme.typography.labelLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = isActive, onClick = { isActive = true }) + Text("Aktiv", Modifier.clickable { isActive = true }) + Spacer(Modifier.width(16.dp)) + RadioButton(selected = !isActive, onClick = { isActive = false }) + Text("Deaktiviert", Modifier.clickable { isActive = false }) + } + SelectionField(label = "Abteilungsleiter", value = state.departmentStaffOptions.firstOrNull { it.id == managerId }?.name ?: "— Keine(r) —", expanded = managerExpanded, onExpand = { managerExpanded = true }, onDismiss = { managerExpanded = false }) { + DropdownMenuItem(text = { Text("— Keine(r) —") }, onClick = { managerId = null; managerExpanded = false }) + state.departmentStaffOptions.forEach { m -> DropdownMenuItem(text = { Text(m.name) }, onClick = { managerId = m.id; managerExpanded = false }) } + } + SelectionField(label = "SLA-Plan", value = state.departmentSlaPlans.firstOrNull { it.id == slaPlanId }?.name ?: "Zeiten der Priorität verwenden", expanded = slaExpanded, onExpand = { slaExpanded = true }, onDismiss = { slaExpanded = false }) { + DropdownMenuItem(text = { Text("Zeiten der Priorität verwenden") }, onClick = { slaPlanId = null; slaExpanded = false }) + state.departmentSlaPlans.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { slaPlanId = p.id; slaExpanded = false }) } + } + SelectionField(label = "Abteilungs-E-Mail (optional)", value = email.ifBlank { "Automatisch (Standardadresse der Abteilung)" }, expanded = emailExpanded, onExpand = { emailExpanded = true }, onDismiss = { emailExpanded = false }) { + DropdownMenuItem(text = { Text("Automatisch (Standardadresse der Abteilung)") }, onClick = { email = ""; emailExpanded = false }) + state.departmentEmailOptions.forEach { o -> + DropdownMenuItem(text = { Text("${o.email} (${o.departmentName})") }, onClick = { email = o.email; emailExpanded = false }) + } + if (email.isNotBlank() && state.departmentEmailOptions.none { it.email.equals(email, ignoreCase = true) }) { + DropdownMenuItem(text = { Text(email) }, onClick = { emailExpanded = false }) + } + } + Text("Adressen verwaltest du unter „E-Mail-Adressen“.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isDefault, onCheckedChange = { isDefault = it }) + Text("Standardabteilung") + } + SignatureField( + value = signature, + onValueChange = { signature = it }, + uploading = state.imageUploadLoading, + uploadError = state.imageUploadError, + onUpload = onUploadImage, + ) + OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Administratornotizen (intern)") }, minLines = 3, shape = MaterialTheme.shapes.medium) + } else { + val members = dept?.members.orEmpty() + if (members.isEmpty()) { + Text( + if (dept == null) "Mitglieder lassen sich nach dem Erstellen unter „Mitarbeiter“ zuweisen." else "Dieser Abteilung sind noch keine Mitarbeiter zugewiesen. Die Zuweisung erfolgt unter „Mitarbeiter“.", + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + members.forEach { m -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(12.dp)) { + Text(m.name + if (m.id == managerId) " (Leiter)" else "", fontWeight = FontWeight.SemiBold) + Text(if (m.isPrimary) "Hauptabteilung" else "Zusätzlich", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + Text("Zuweisungen änderst du unter „Mitarbeiter“.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + state.departments2MutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + onSave(dept?.id, DepartmentInput(name.trim(), email.trim(), isDefault, isActive, managerId, slaPlanId, notes.trim(), signature.trim().ifBlank { null })) { editingId = null; isCreating = false; tab = 0 } + }, + shape = MaterialTheme.shapes.medium, + enabled = !state.departments2MutationLoading && name.isNotBlank(), + ) { Text(if (state.departments2MutationLoading) "Wird gespeichert …" else "Änderungen speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + } + items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Abteilungen.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(items) { dept -> DepartmentRowAdmin(dept, onEdit = { editingId = dept.id }, onDelete = { onDelete(dept.id) }) } + } + } + } +} + +@Composable +private fun DepartmentRowAdmin(dept: Department, onEdit: () -> Unit, onDelete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(dept.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + if (!dept.isActive) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) { + Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.width(6.dp)) + } + if (dept.isDefault) { + Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) { + Text("Standard", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + } + } + Text( + "${dept.members.size} Mitglied(er)" + (dept.managerName?.let { " · Leiter: $it" } ?: "") + " · " + dept.email.ifBlank { "keine Abteilungs-E-Mail" }, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = onEdit) { Text("Bearbeiten") } + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +// ─── Berichte ──────────────────────────────────────────────────────────────── + +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun AdminReportsScreen( + state: DeskHubState, + onBack: () -> Unit, + onRun: (de.deskhub.staff.data.remote.dto.ReportConfigDto?, Int?) -> Unit, + onSave: (String, Boolean) -> Unit, + onDelete: (Int) -> Unit, +) { + LaunchedEffect(Unit) { if (state.report == null) onRun(null, null) } + val report = state.report + var dimension by remember(report?.config?.dimension) { mutableStateOf(state.reportConfig.dimension) } + var from by remember(report?.config?.from) { mutableStateOf(state.reportConfig.from) } + var to by remember(report?.config?.to) { mutableStateOf(state.reportConfig.to) } + var metrics by remember(report?.config?.metrics) { mutableStateOf(state.reportConfig.metrics.toSet()) } + var dimExpanded by remember { mutableStateOf(false) } + var showSave by remember { mutableStateOf(false) } + + fun format(value: Any?, unit: String): String { + val n = (value as? Number)?.toDouble() ?: return "–" + return when (unit) { + "%" -> String.format(java.util.Locale.GERMANY, "%.1f %%", n) + "Std" -> String.format(java.util.Locale.GERMANY, "%.1f Std", n) + else -> n.toLong().toString() + } + } + + if (showSave) { + var name by remember { mutableStateOf("") } + var shared by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = { showSave = false }, + title = { Text("Bericht speichern") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = shared, onCheckedChange = { shared = it }) + Text("Für alle Mitarbeiter sichtbar") + } + } + }, + confirmButton = { TextButton(enabled = name.isNotBlank(), onClick = { onSave(name.trim(), shared); showSave = false }) { Text("Speichern") } }, + dismissButton = { TextButton(onClick = { showSave = false }) { Text("Abbrechen") } }, + ) + } + + Scaffold(topBar = { AdminTopBar("Berichte", onBack) }) { padding -> + Column( + modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (report?.saved?.isNotEmpty() == true) { + Text("Gespeicherte Berichte", style = MaterialTheme.typography.labelLarge) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(report.saved) { sr -> + androidx.compose.material3.InputChip( + selected = false, + onClick = { onRun(null, sr.id) }, + label = { Text(sr.name + if (sr.isShared) " ⇄" else "") }, + trailingIcon = if (sr.mine) ({ Icon(Icons.Filled.Close, contentDescription = "Löschen", modifier = Modifier.size(16.dp).clickable { onDelete(sr.id) }) }) else null, + ) + } + } + } + SelectionField(label = "Gruppieren nach", value = report?.dimensions?.firstOrNull { it.key == dimension }?.label ?: dimension, expanded = dimExpanded, onExpand = { dimExpanded = true }, onDismiss = { dimExpanded = false }) { + report?.dimensions?.forEach { d -> DropdownMenuItem(text = { Text(d.label) }, onClick = { dimension = d.key; dimExpanded = false }) } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField(value = from, onValueChange = { from = it }, modifier = Modifier.weight(1f), label = { Text("Von (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = to, onValueChange = { to = it }, modifier = Modifier.weight(1f), label = { Text("Bis (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + } + Text("Kennzahlen", style = MaterialTheme.typography.labelLarge) + androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + report?.metrics?.forEach { m -> + FilterChip( + selected = m.key in metrics, + onClick = { metrics = if (m.key in metrics) metrics - m.key else metrics + m.key }, + label = { Text(m.label) }, + shape = MaterialTheme.shapes.small, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onRun(state.reportConfig.copy(dimension = dimension, from = from, to = to, metrics = metrics.toList().ifEmpty { listOf("total") }), null) }, + enabled = !state.reportLoading, + shape = MaterialTheme.shapes.medium, + ) { Text(if (state.reportLoading) "Wird berechnet …" else "Bericht erstellen") } + OutlinedButton(onClick = { showSave = true }, enabled = report != null, shape = MaterialTheme.shapes.medium) { Text("Speichern") } + } + state.reportError?.let { ErrorText(it) } + if (report != null) { + if (report.rows.isEmpty()) { + Text("Keine Tickets im gewählten Zeitraum.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + report.rows.forEach { row -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(row["label"]?.toString() ?: "–", style = MaterialTheme.typography.titleSmall) + report.columns.drop(1).forEach { c -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(c.label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(format(row[c.key], c.unit), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } + } + } + } + } + if (report.rows.isNotEmpty()) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Gesamt", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onPrimaryContainer) + report.columns.drop(1).forEach { c -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(c.label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer) + Text(format(report.totals[c.key], c.unit), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer) + } + } + } + } + } + } + } + } +} + +// ─── Admin: SLA-Pläne ──────────────────────────────────────────────────────── + +@Composable +private fun AdminSlaPlansScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, String, Int, Int, Boolean, String, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val items = state.slaPlans + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("SLA-Pläne", onBack) }, + floatingActionButton = { + if (items != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neuer SLA-Plan") } + } + }, + ) { padding -> + when { + state.slaPlansLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("SLA-Pläne werden geladen …") } + state.slaPlansError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.slaPlansError, onLoad) } + items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val plan = items.firstOrNull { it.id == editingId } + var name by rememberSaveable(plan?.id) { mutableStateOf(plan?.name ?: "") } + var isActive by rememberSaveable(plan?.id) { mutableStateOf(plan?.isActive ?: true) } + var responseHours by rememberSaveable(plan?.id) { mutableStateOf((plan?.responseHours ?: 4).toString()) } + var resolutionHours by rememberSaveable(plan?.id) { mutableStateOf((plan?.resolutionHours ?: 24).toString()) } + var notes by rememberSaveable(plan?.id) { mutableStateOf(plan?.notes ?: "") } + Column( + modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (plan == null) "Neuer SLA-Plan" else "SLA-Plan aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = isActive, onClick = { isActive = true }) + Text("Aktiv", Modifier.clickable { isActive = true }) + Spacer(Modifier.width(16.dp)) + RadioButton(selected = !isActive, onClick = { isActive = false }) + Text("Deaktiviert", Modifier.clickable { isActive = false }) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField(value = responseHours, onValueChange = { responseHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Reaktion (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + OutlinedTextField(value = resolutionHours, onValueChange = { resolutionHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Lösung (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + Text("Ersetzt die Zeiten der Priorität für Tickets, deren Hilfethema oder Abteilung diesen Plan nutzt (Hilfethema vor Abteilung).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Administratornotizen (intern)") }, minLines = 3, shape = MaterialTheme.shapes.medium) + state.slaPlansMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onSave(plan?.id, name.trim(), responseHours.toIntOrNull() ?: 0, resolutionHours.toIntOrNull() ?: 0, isActive, notes.trim()) { editingId = null; isCreating = false } }, + shape = MaterialTheme.shapes.medium, + enabled = !state.slaPlansMutationLoading && name.isNotBlank(), + ) { Text(if (state.slaPlansMutationLoading) "Wird gespeichert …" else "Änderungen speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine SLA-Pläne angelegt.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(items) { plan -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(plan.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + if (!plan.isActive) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) { + Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + } + } + Text("Reaktion ${plan.responseHours} Std · Lösung ${plan.resolutionHours} Std", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("${plan.departmentCount} Abteilung(en) · ${plan.helpTopicCount} Hilfethema/-themen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = { editingId = plan.id }) { Text("Bearbeiten") } + TextButton(onClick = { onDelete(plan.id) }) { Text("Löschen") } + } + } + } + } + } + } + } +} + +// ─── Admin: Prioritäten ────────────────────────────────────────────────────── + +@Composable +private fun AdminPrioritiesScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, String, String, Int, Int, Int, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val items = state.prioritiesAdmin + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("Prioritäten / SLA", onBack) }, + floatingActionButton = { + if (items != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Priorität") } + } + }, + ) { padding -> + when { + state.prioritiesAdminLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Prioritäten werden geladen …") } + state.prioritiesAdminError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.prioritiesAdminError, onLoad) } + items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val prio = items.firstOrNull { it.id == editingId } + var name by rememberSaveable(prio?.id) { mutableStateOf(prio?.name ?: "") } + var color by rememberSaveable(prio?.id) { mutableStateOf(prio?.color ?: "#2563eb") } + var responseHours by rememberSaveable(prio?.id) { mutableStateOf((prio?.responseHours ?: 24).toString()) } + var resolutionHours by rememberSaveable(prio?.id) { mutableStateOf((prio?.resolutionHours ?: 72).toString()) } + var sortOrder by rememberSaveable(prio?.id) { mutableStateOf((prio?.sortOrder ?: 0).toString()) } + Column( + modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (prio == null) "Neue Priorität" else "Priorität bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = color, onValueChange = { color = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Farbe (#RRGGBB)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField(value = responseHours, onValueChange = { responseHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Reaktion (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + OutlinedTextField(value = resolutionHours, onValueChange = { resolutionHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Lösung (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + OutlinedTextField(value = sortOrder, onValueChange = { sortOrder = it.filter(Char::isDigit) }, modifier = Modifier.fillMaxWidth(), label = { Text("Sortierung") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + state.prioritiesAdminMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + onSave(prio?.id, name.trim(), color.trim(), responseHours.toIntOrNull() ?: 24, resolutionHours.toIntOrNull() ?: 72, sortOrder.toIntOrNull() ?: 0) { + editingId = null; isCreating = false + } + }, + shape = MaterialTheme.shapes.medium, + enabled = !state.prioritiesAdminMutationLoading && name.isNotBlank(), + ) { Text(if (state.prioritiesAdminMutationLoading) "Wird gespeichert …" else "Speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Prioritäten.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(items) { prio -> PriorityRowAdmin(prio, onEdit = { editingId = prio.id }, onDelete = { onDelete(prio.id) }) } + } + } + } +} + +@Composable +private fun PriorityRowAdmin(prio: PriorityFull, onEdit: () -> Unit, onDelete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface(color = parseHexColor(prio.color, MaterialTheme.colorScheme.primary).copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) { + Text(prio.name, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelMedium, color = parseHexColor(prio.color, MaterialTheme.colorScheme.primary), fontWeight = FontWeight.SemiBold) + } + } + Text("Reaktion: ${prio.responseHours}h · Lösung: ${prio.resolutionHours}h", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp)) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = onEdit) { Text("Bearbeiten") } + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +// ─── Admin: Hilfe-Themen ───────────────────────────────────────────────────── + +@Composable +private fun AdminHelpTopicsScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, HelpTopicInput, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.helpTopics + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("Hilfethemen", onBack) }, + floatingActionButton = { + if (data != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neues Hilfethema") } + } + }, + ) { padding -> + when { + state.helpTopicsLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Hilfethemen werden geladen …") } + state.helpTopicsError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.helpTopicsError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val topic = data.topics.firstOrNull { it.id == editingId } + var tab by rememberSaveable { mutableStateOf(0) } + var name by rememberSaveable(topic?.id) { mutableStateOf(topic?.name ?: "") } + var isActive by rememberSaveable(topic?.id) { mutableStateOf(topic?.isActive ?: true) } + var isPublic by rememberSaveable(topic?.id) { mutableStateOf(topic?.isPublic ?: true) } + var parentId by rememberSaveable(topic?.id) { mutableStateOf(topic?.parentId) } + var notes by rememberSaveable(topic?.id) { mutableStateOf(topic?.notes ?: "") } + var departmentId by rememberSaveable(topic?.id) { mutableStateOf(topic?.departmentId ?: data.departments.firstOrNull()?.id ?: 0) } + var priorityId by rememberSaveable(topic?.id) { mutableStateOf(topic?.priorityId ?: data.priorities.firstOrNull()?.id ?: 0) } + var sortOrder by rememberSaveable(topic?.id) { mutableStateOf((topic?.sortOrder ?: 0).toString()) } + var deptExpanded by remember { mutableStateOf(false) } + var prioExpanded by remember { mutableStateOf(false) } + var parentExpanded by remember { mutableStateOf(false) } + var statusExpanded by remember { mutableStateOf(false) } + var topicSlaPlanId by rememberSaveable(topic?.id) { mutableStateOf(topic?.slaPlanId) } + var topicSlaExpanded by remember { mutableStateOf(false) } + // Eigenes Thema und dessen Unterthemen kommen als Elternthema nicht in Frage + val descendants = mutableSetOf().also { set -> + if (topic != null) { + set.add(topic.id) + var grew = true + while (grew) { grew = false; data.topics.forEach { t -> if (t.parentId in set && set.add(t.id)) grew = true } } + } + } + Column(Modifier.padding(padding).fillMaxSize()) { + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Informationen") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Optionen bei neuem Ticket") }) + } + Column( + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (topic == null) "Neues Hilfethema" else "Hilfethema aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (tab == 0) { + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Thema") }, singleLine = true, shape = MaterialTheme.shapes.medium) + SelectionField(label = "Status", value = if (isActive) "Aktiv" else "Deaktiviert", expanded = statusExpanded, onExpand = { statusExpanded = true }, onDismiss = { statusExpanded = false }) { + DropdownMenuItem(text = { Text("Aktiv") }, onClick = { isActive = true; statusExpanded = false }) + DropdownMenuItem(text = { Text("Deaktiviert") }, onClick = { isActive = false; statusExpanded = false }) + } + Text("Typ", style = MaterialTheme.typography.labelLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = isPublic, onClick = { isPublic = true }) + Text("Öffentlich", Modifier.clickable { isPublic = true }) + Spacer(Modifier.width(16.dp)) + RadioButton(selected = !isPublic, onClick = { isPublic = false }) + Text("Vertraulich/Intern", Modifier.clickable { isPublic = false }) + } + SelectionField(label = "Übergeordnetes Thema", value = data.topics.firstOrNull { it.id == parentId }?.path ?: "— Oberste Hilfethemenebene —", expanded = parentExpanded, onExpand = { parentExpanded = true }, onDismiss = { parentExpanded = false }) { + DropdownMenuItem(text = { Text("— Oberste Hilfethemenebene —") }, onClick = { parentId = null; parentExpanded = false }) + data.topics.filter { it.id !in descendants }.forEach { t -> DropdownMenuItem(text = { Text(t.path) }, onClick = { parentId = t.id; parentExpanded = false }) } + } + OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Interne Notizen (vertraulich)") }, minLines = 4, shape = MaterialTheme.shapes.medium) + } else { + SelectionField(label = "Abteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) { + data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; deptExpanded = false }) } + } + SelectionField(label = "Priorität", value = data.priorities.firstOrNull { it.id == priorityId }?.name ?: "–", expanded = prioExpanded, onExpand = { prioExpanded = true }, onDismiss = { prioExpanded = false }) { + data.priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { priorityId = p.id; prioExpanded = false }) } + } + SelectionField(label = "SLA-Plan", value = data.slaPlans.firstOrNull { it.id == topicSlaPlanId }?.name ?: "Abteilung/Priorität bestimmt die Zeiten", expanded = topicSlaExpanded, onExpand = { topicSlaExpanded = true }, onDismiss = { topicSlaExpanded = false }) { + DropdownMenuItem(text = { Text("Abteilung/Priorität bestimmt die Zeiten") }, onClick = { topicSlaPlanId = null; topicSlaExpanded = false }) + data.slaPlans.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { topicSlaPlanId = p.id; topicSlaExpanded = false }) } + } + OutlinedTextField(value = sortOrder, onValueChange = { sortOrder = it.filter(Char::isDigit) }, modifier = Modifier.fillMaxWidth(), label = { Text("Sortierung") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + state.helpTopicsMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + onSave(topic?.id, HelpTopicInput(name.trim(), isActive, isPublic, parentId, topicSlaPlanId, notes.trim(), departmentId, priorityId, sortOrder.toIntOrNull() ?: 0)) { editingId = null; isCreating = false; tab = 0 } + }, + shape = MaterialTheme.shapes.medium, + enabled = !state.helpTopicsMutationLoading && name.isNotBlank() && departmentId != 0 && priorityId != 0, + ) { Text(if (state.helpTopicsMutationLoading) "Wird gespeichert …" else "Änderungen speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + } + data.topics.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Hilfethemen.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(data.topics.sortedBy { it.path.lowercase() }) { topic -> HelpTopicRowAdmin(topic, onEdit = { editingId = topic.id }, onDelete = { onDelete(topic.id) }) } + } + } + } +} + +@Composable +private fun HelpTopicRowAdmin(topic: HelpTopic, onEdit: () -> Unit, onDelete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(topic.path, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + if (!topic.isPublic) { + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.extraSmall) { + Text("Intern", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.width(6.dp)) + } + if (!topic.isActive) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) { + Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + } + } + Text("${topic.departmentName} · ${topic.priorityName}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = onEdit) { Text("Bearbeiten") } + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +// ─── Admin: Organisationen ─────────────────────────────────────────────────── + +@Composable +private fun AdminOrganizationsScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, String, String?, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val items = state.organizations + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("Organisationen", onBack) }, + floatingActionButton = { + if (items != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Organisation") } + } + }, + ) { padding -> + when { + state.organizationsLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Organisationen werden geladen …") } + state.organizationsError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.organizationsError, onLoad) } + items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val org = items.firstOrNull { it.id == editingId } + var name by rememberSaveable(org?.id) { mutableStateOf(org?.name ?: "") } + var notes by rememberSaveable(org?.id) { mutableStateOf(org?.notes ?: "") } + Column( + modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (org == null) "Neue Organisation" else "Organisation bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Notizen (optional)") }, minLines = 3, shape = MaterialTheme.shapes.medium) + state.organizationsMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onSave(org?.id, name.trim(), notes.trim().ifBlank { null }) { editingId = null; isCreating = false } }, + shape = MaterialTheme.shapes.medium, + enabled = !state.organizationsMutationLoading && name.isNotBlank(), + ) { Text(if (state.organizationsMutationLoading) "Wird gespeichert …" else "Speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Organisationen.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(items) { org -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Text(org.name, fontWeight = FontWeight.SemiBold) + Text("${org.memberCount} Kontakte", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = { editingId = org.id }) { Text("Bearbeiten") } + TextButton(onClick = { onDelete(org.id) }) { Text("Löschen") } + } + } + } + } + } + } + } +} + +// ─── Admin: Teams ──────────────────────────────────────────────────────────── + +@Composable +private fun AdminTeamsScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, String, Int?, String?, List, () -> Unit) -> Unit, + onToggle: (Int) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.teams + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("Teams", onBack) }, + floatingActionButton = { + if (data != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neues Team") } + } + }, + ) { padding -> + when { + state.teamsLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Teams werden geladen …") } + state.teamsError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.teamsError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val team = data.teams.firstOrNull { it.id == editingId } + var name by rememberSaveable(team?.id) { mutableStateOf(team?.name ?: "") } + var leadId by rememberSaveable(team?.id) { mutableStateOf(team?.leadId) } + var notes by rememberSaveable(team?.id) { mutableStateOf(team?.notes ?: "") } + var memberIds by rememberSaveable(team?.id) { mutableStateOf(team?.memberIds?.toSet() ?: emptySet()) } + var leadExpanded by remember { mutableStateOf(false) } + Column( + modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (team == null) "Neues Team" else "Team bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + SelectionField(label = "Teamleiter", value = data.staffOptions.firstOrNull { it.id == leadId }?.name ?: "– kein Leiter –", expanded = leadExpanded, onExpand = { leadExpanded = true }, onDismiss = { leadExpanded = false }) { + DropdownMenuItem(text = { Text("– kein Leiter –") }, onClick = { leadId = null; leadExpanded = false }) + data.staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { leadId = s.id; leadExpanded = false }) } + } + Text("Mitglieder", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + Column( + Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.medium).padding(10.dp).heightIn(max = 220.dp).verticalScroll(rememberScrollState()), + ) { + data.staffOptions.forEach { s -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Checkbox( + checked = memberIds.contains(s.id), + onCheckedChange = { checked -> memberIds = if (checked) memberIds + s.id else memberIds - s.id }, + ) + Text(s.name) + } + } + } + OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Notizen (optional)") }, minLines = 2, shape = MaterialTheme.shapes.medium) + state.teamsMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onSave(team?.id, name.trim(), leadId, notes.trim().ifBlank { null }, memberIds.toList()) { editingId = null; isCreating = false } }, + shape = MaterialTheme.shapes.medium, + enabled = !state.teamsMutationLoading && name.isNotBlank(), + ) { Text(if (state.teamsMutationLoading) "Wird gespeichert …" else "Speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + data.teams.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Teams.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(data.teams) { team -> TeamRowAdmin(team, onEdit = { editingId = team.id }, onToggle = { onToggle(team.id) }, onDelete = { onDelete(team.id) }) } + } + } + } +} + +@Composable +private fun TeamRowAdmin(team: Team, onEdit: () -> Unit, onToggle: () -> Unit, onDelete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(team.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Surface(color = if (team.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) { + Text(if (team.isActive) "Aktiv" else "Inaktiv", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + } + Text("Leiter: ${team.leadName ?: "–"} · ${team.memberCount} Mitglieder", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = onEdit) { Text("Bearbeiten") } + TextButton(onClick = onToggle) { Text(if (team.isActive) "Deaktivieren" else "Aktivieren") } + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +// ─── Admin: Rollen ─────────────────────────────────────────────────────────── + +private val TicketPermissionLabels = linkedMapOf( + "reply_ticket" to "Tickets beantworten", + "close_ticket" to "Tickets schließen/lösen", + "delete_ticket" to "Tickets löschen", + "transfer_ticket" to "Tickets übertragen", + "post_note" to "Interne Notizen schreiben", + "assign_ticket" to "Tickets zuweisen", + "manage_canned" to "Textbausteine verwalten", + "view_reports" to "Berichte einsehen", + "manage_kb" to "Wissensdatenbank verwalten", + "create_ticket" to "Tickets im Namen von Kunden erstellen", +) + +/** Feinkörnige Adminportal-Bereichsrechte – muss zu admin_section_permissions() in includes/functions.php passen. */ +private val AdminSectionPermissionLabels = linkedMapOf( + "manage_departments" to "Abteilungen verwalten", + "manage_priorities" to "Prioritäten / SLA verwalten", + "manage_help_topics" to "Hilfe-Themen verwalten", + "manage_email_addresses" to "E-Mail-Adressen verwalten", + "manage_email_templates" to "E-Mail-Vorlagen verwalten", + "manage_organizations" to "Organisationen verwalten", + "manage_teams" to "Teams verwalten", + "manage_roles" to "Rollen verwalten", + "manage_staff" to "Mitarbeiter verwalten", + "view_audit_log" to "Admin-Protokoll einsehen", + "manage_escalations" to "Eskalationsregeln verwalten", + "view_time_entries" to "Zeiterfassung einsehen", + "manage_business_hours" to "Geschäftszeiten verwalten", +) + +private val RolePermissionLabels = TicketPermissionLabels + AdminSectionPermissionLabels + +/** Ist true für echte Admins UND für Agenten, denen eine der obigen Berechtigungen zugewiesen wurde. */ +private fun Staff?.hasAnyAdminAccess(): Boolean { + if (this == null) return false + if (role == "admin") return true + return AdminSectionPermissionLabels.keys.any { permissions[it] == true } || permissions["view_reports"] == true +} + +@Composable +private fun AdminRolesScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, String, Map, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.roles + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("Rollen", onBack) }, + floatingActionButton = { + if (data != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Rolle") } + } + }, + ) { padding -> + when { + state.rolesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Rollen werden geladen …") } + state.rolesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.rolesError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val role = data.roles.firstOrNull { it.id == editingId } + var name by rememberSaveable(role?.id) { mutableStateOf(role?.name ?: "") } + var perms by rememberSaveable(role?.id) { mutableStateOf(role?.permissions ?: emptyMap()) } + Column( + modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (role == null) "Neue Rolle" else "Rolle bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Text("Ticket-Berechtigungen", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + TicketPermissionLabels.forEach { (key, label) -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox( + checked = perms[key] == true, + onCheckedChange = { checked -> perms = perms + (key to checked) }, + ) + Text(label, style = MaterialTheme.typography.bodyMedium) + } + } + Text("Adminportal-Bereiche (\"Co-Admin\")", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + AdminSectionPermissionLabels.forEach { (key, label) -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox( + checked = perms[key] == true, + onCheckedChange = { checked -> perms = perms + (key to checked) }, + ) + Text(label, style = MaterialTheme.typography.bodyMedium) + } + } + state.rolesMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onSave(role?.id, name.trim(), perms) { editingId = null; isCreating = false } }, + shape = MaterialTheme.shapes.medium, + enabled = !state.rolesMutationLoading && name.isNotBlank(), + ) { Text(if (state.rolesMutationLoading) "Wird gespeichert …" else "Speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + data.roles.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine benutzerdefinierten Rollen. Ohne eigene Rolle gelten die Standard-Agentenrechte.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(data.roles) { role -> RoleRowAdmin(role, onEdit = { editingId = role.id }, onDelete = { onDelete(role.id) }) } + } + } + } +} + +@Composable +private fun RoleRowAdmin(role: Role, onEdit: () -> Unit, onDelete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Text(role.name, fontWeight = FontWeight.SemiBold) + Text("${role.staffCount} Mitarbeiter", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + val granted = role.permissions.filterValues { it }.keys.mapNotNull { RolePermissionLabels[it] } + Text( + if (granted.isEmpty()) "Keine Berechtigungen" else granted.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = onEdit) { Text("Bearbeiten") } + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +// ─── Admin: Mitarbeiter ────────────────────────────────────────────────────── + +@Composable +private fun AdminStaffScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, StaffAdminInput, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, + onUploadImage: (ByteArray, String, String, (String) -> Unit) -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.staffAdmin + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("Mitarbeiter", onBack) }, + floatingActionButton = { + if (data != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neuer Mitarbeiter") } + } + }, + ) { padding -> + when { + state.staffAdminLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Mitarbeiter werden geladen …") } + state.staffAdminError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.staffAdminError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val person = data.staff.firstOrNull { it.id == editingId } + var tab by rememberSaveable { mutableStateOf(0) } + var name by rememberSaveable(person?.id) { mutableStateOf(person?.name ?: "") } + var email by rememberSaveable(person?.id) { mutableStateOf(person?.email ?: "") } + var role by rememberSaveable(person?.id) { mutableStateOf(person?.role ?: "agent") } + var roleId by rememberSaveable(person?.id) { mutableStateOf(person?.roleId) } + var departmentId by rememberSaveable(person?.id) { mutableStateOf(person?.departmentId) } + var isActive by rememberSaveable(person?.id) { mutableStateOf(person?.isActive ?: true) } + var password by rememberSaveable(person?.id) { mutableStateOf("") } + var signature by rememberSaveable(person?.id) { mutableStateOf(person?.signature ?: "") } + var roleExpanded by remember { mutableStateOf(false) } + var customRoleExpanded by remember { mutableStateOf(false) } + var deptExpanded by remember { mutableStateOf(false) } + var addExpanded by remember { mutableStateOf(false) } + var extraDepts by rememberSaveable(person?.id) { + mutableStateOf(person?.extraDepartments?.associate { it.departmentId to it.notify } ?: emptyMap()) + } + // Rolle pro erweiterter Abteilung (0 = Hauptrolle) + var extraRoles by rememberSaveable(person?.id) { + mutableStateOf(person?.extraDepartments?.associate { it.departmentId to (it.roleId ?: 0) } ?: emptyMap()) + } + var roleMenuFor by remember { mutableStateOf(null) } + var teamIds by rememberSaveable(person?.id) { mutableStateOf(person?.teamIds ?: emptyList()) } + + Column(Modifier.padding(padding).fillMaxSize()) { + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Konto") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Zugang") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Berechtigungen") }) + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Teams") }) + } + Column( + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (person == null) "Neuer Agent" else "Agenten verwalten — ${person.name}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + when (tab) { + 0 -> { + OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = email, onValueChange = { email = it }, modifier = Modifier.fillMaxWidth(), label = { Text("E-Mail-Adresse") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)) + Text("Status", style = MaterialTheme.typography.labelLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = isActive, onClick = { isActive = true }) + Text("Aktiv", Modifier.clickable { isActive = true }) + Spacer(Modifier.width(16.dp)) + RadioButton(selected = !isActive, onClick = { isActive = false }) + Text("Gesperrt", Modifier.clickable { isActive = false }) + } + OutlinedTextField( + value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth(), + label = { Text(if (person == null) "Passwort" else "Neues Passwort (leer = unverändert)") }, + singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(), + ) + SignatureField( + value = signature, + onValueChange = { signature = it }, + uploading = state.imageUploadLoading, + uploadError = state.imageUploadError, + onUpload = onUploadImage, + minLines = 2, + ) + } + 1 -> { + Text("Wähle die Abteilungen, zu denen der Agent Zugang haben soll.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + SelectionField(label = "Hauptabteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) { + DropdownMenuItem(text = { Text("–") }, onClick = { departmentId = null; deptExpanded = false }) + data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; extraDepts = extraDepts - d.id; deptExpanded = false }) } + } + Text("Erweiterter Zugriff", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + data.departments.filter { extraDepts.containsKey(it.id) && it.id != departmentId }.forEach { d -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text(d.name, modifier = Modifier.weight(1f)) + Box { + TextButton(onClick = { roleMenuFor = d.id }) { Text(data.roles.firstOrNull { it.id == extraRoles[d.id] }?.name ?: "Hauptrolle") } + DropdownMenu(expanded = roleMenuFor == d.id, onDismissRequest = { roleMenuFor = null }) { + DropdownMenuItem(text = { Text("Hauptrolle") }, onClick = { extraRoles = extraRoles + (d.id to 0); roleMenuFor = null }) + data.roles.forEach { r -> DropdownMenuItem(text = { Text(r.name) }, onClick = { extraRoles = extraRoles + (d.id to r.id); roleMenuFor = null }) } + } + } + Checkbox(checked = extraDepts[d.id] == true, onCheckedChange = { notify -> extraDepts = extraDepts + (d.id to notify) }) + Text("Benachr.", style = MaterialTheme.typography.labelSmall) + TextButton(onClick = { extraDepts = extraDepts - d.id; extraRoles = extraRoles - d.id }) { Text("✕") } + } + } + val addable = data.departments.filter { it.id != departmentId && !extraDepts.containsKey(it.id) } + if (addable.isNotEmpty()) { + SelectionField(label = "Abteilung hinzufügen", value = "— Abteilung auswählen —", expanded = addExpanded, onExpand = { addExpanded = true }, onDismiss = { addExpanded = false }) { + addable.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { extraDepts = extraDepts + (d.id to true); addExpanded = false }) } + } + } + } + 2 -> { + SelectionField(label = "Kontotyp", value = if (role == "admin") "Administrator (alle Rechte)" else "Mitarbeiter", expanded = roleExpanded, onExpand = { roleExpanded = true }, onDismiss = { roleExpanded = false }) { + DropdownMenuItem(text = { Text("Mitarbeiter") }, onClick = { role = "agent"; roleExpanded = false }) + DropdownMenuItem(text = { Text("Administrator (alle Rechte)") }, onClick = { role = "admin"; roleExpanded = false }) + } + if (role != "admin" && data.roles.isNotEmpty()) { + SelectionField(label = "Rolle", value = data.roles.firstOrNull { it.id == roleId }?.name ?: "– Standard-Mitarbeiterrechte –", expanded = customRoleExpanded, onExpand = { customRoleExpanded = true }, onDismiss = { customRoleExpanded = false }) { + DropdownMenuItem(text = { Text("– Standard-Mitarbeiterrechte –") }, onClick = { roleId = null; customRoleExpanded = false }) + data.roles.forEach { r -> DropdownMenuItem(text = { Text(r.name) }, onClick = { roleId = r.id; customRoleExpanded = false }) } + } + } + Text("Rollen und ihre Rechte legst du unter „Rollen“ an.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + else -> { + if (data.teams.isEmpty()) { + Text("Es sind noch keine Teams angelegt.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + Text( + if (data.canManageTeams) "Der Agent ist Mitglied in folgenden Teams." else "Zum Ändern fehlt das Recht „Teams verwalten“.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + data.teams.forEach { t -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = t.id in teamIds, enabled = data.canManageTeams, + onCheckedChange = { on -> teamIds = if (on) teamIds + t.id else teamIds - t.id }, + ) + Text(t.name) + } + } + } + } + } + state.staffAdminMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + onSave( + person?.id, + StaffAdminInput( + name = name.trim(), email = email.trim(), role = role, roleId = if (role == "admin") null else roleId, departmentId = departmentId, + isActive = isActive, password = password, signature = signature.trim().ifBlank { null }, + extraDepartments = extraDepts.filterKeys { it != departmentId }.map { (deptId, notify) -> StaffExtraDept(deptId, notify, extraRoles[deptId]?.takeIf { it != 0 }) }, + teamIds = if (data.canManageTeams) teamIds else null, + ), + ) { editingId = null; isCreating = false; tab = 0 } + }, + shape = MaterialTheme.shapes.medium, + enabled = !state.staffAdminMutationLoading && name.isNotBlank() && email.isNotBlank() && (person != null || password.isNotBlank()), + ) { Text(if (state.staffAdminMutationLoading) "Wird gespeichert …" else "Änderungen speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + if (person != null) { + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + OutlinedButton( + onClick = { onDelete(person.id); editingId = null }, + shape = MaterialTheme.shapes.medium, + colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Mitarbeiter löschen") } + } + } + } + } + data.staff.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Keine Mitarbeiter gefunden.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(data.staff) { s -> StaffRowAdmin(s, onEdit = { editingId = s.id }) } + } + } + } +} + +@Composable +private fun StaffRowAdmin(s: StaffAdmin, onEdit: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = onEdit) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + if (!s.isActive) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) { + Text("Gesperrt", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + } + } + Text(s.email, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "${if (s.role == "admin") "Administrator" else "Mitarbeiter"} · ${s.departmentName ?: "keine Abteilung"}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ─── Admin: Geschäftszeiten ────────────────────────────────────────────────── + +private val BusinessWeekdayNames = linkedMapOf( + 1 to "Montag", 2 to "Dienstag", 3 to "Mittwoch", 4 to "Donnerstag", 5 to "Freitag", 6 to "Samstag", 0 to "Sonntag", +) + +@Composable +private fun AdminBusinessHoursScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSaveHours: (Boolean, List, () -> Unit) -> Unit, + onAddHoliday: (String, String) -> Unit, + onDeleteHoliday: (Int) -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + val data = state.businessHours + + Scaffold(topBar = { AdminTopBar("Geschäftszeiten", onBack) }) { padding -> + when { + state.businessHoursLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Geschäftszeiten werden geladen …") } + state.businessHoursError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.businessHoursError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + else -> { + var enabled by rememberSaveable(data) { mutableStateOf(data.enabled) } + var hours by rememberSaveable(data) { mutableStateOf(data.hours) } + var newHolidayDate by rememberSaveable { mutableStateOf("") } + var newHolidayName by rememberSaveable { mutableStateOf("") } + + LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = enabled, onCheckedChange = { enabled = it }) + Text("Geschäftszeiten für SLA-Berechnung verwenden") + } + } + items(hours) { h -> + val idx = hours.indexOf(h) + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = h.isOpen, onCheckedChange = { on -> hours = hours.toMutableList().also { it[idx] = h.copy(isOpen = on) } }) + Text(BusinessWeekdayNames[h.weekday] ?: h.weekday.toString(), fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + } + if (h.isOpen) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = h.openTime, onValueChange = { v -> hours = hours.toMutableList().also { it[idx] = h.copy(openTime = v) } }, + modifier = Modifier.weight(1f), label = { Text("Von (HH:MM)") }, singleLine = true, shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = h.closeTime, onValueChange = { v -> hours = hours.toMutableList().also { it[idx] = h.copy(closeTime = v) } }, + modifier = Modifier.weight(1f), label = { Text("Bis (HH:MM)") }, singleLine = true, shape = MaterialTheme.shapes.medium, + ) + } + } + } + } + } + item { + state.businessHoursMutationError?.let { ErrorText(it) } + Button( + onClick = { onSaveHours(enabled, hours) {} }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + enabled = !state.businessHoursMutationLoading, + ) { Text(if (state.businessHoursMutationLoading) "Wird gespeichert …" else "Öffnungszeiten speichern") } + } + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)); Text("Feiertage", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) } + if (data.holidays.isEmpty()) { + item { EmptyState("Noch keine Feiertage eingetragen.") } + } + items(data.holidays) { h -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Row(Modifier.fillMaxWidth().padding(14.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column { Text(h.name, fontWeight = FontWeight.Medium); Text(h.date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + TextButton(onClick = { onDeleteHoliday(h.id) }) { Text("Entfernen") } + } + } + } + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = newHolidayDate, onValueChange = { newHolidayDate = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Datum (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = newHolidayName, onValueChange = { newHolidayName = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Button( + onClick = { onAddHoliday(newHolidayDate.trim(), newHolidayName.trim()); newHolidayDate = ""; newHolidayName = "" }, + shape = MaterialTheme.shapes.medium, + enabled = newHolidayDate.isNotBlank() && newHolidayName.isNotBlank(), + ) { Text("Feiertag hinzufügen") } + } + } + } + } + } + } +} + +// ─── Admin: E-Mail-Adressen ────────────────────────────────────────────────── + +@Composable +private fun AdminEmailAddressesScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (Int?, EmailAddressInput, () -> Unit) -> Unit, + onDelete: (Int) -> Unit, + onTest: (Int, String) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + var editingId by rememberSaveable { mutableStateOf(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.emailAddresses + val showForm = isCreating || editingId != null + + Scaffold( + topBar = { AdminTopBar("E-Mail-Adressen", onBack) }, + floatingActionButton = { + if (data != null && !showForm) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Adresse") } + } + }, + ) { padding -> + when { + state.emailAddressesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("E-Mail-Adressen werden geladen …") } + state.emailAddressesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.emailAddressesError, onLoad) } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + showForm -> { + val addr = data.addresses.firstOrNull { it.id == editingId } + var tab by rememberSaveable { mutableStateOf(0) } + // Konto + var email by rememberSaveable(addr?.id) { mutableStateOf(addr?.email ?: "") } + var label by rememberSaveable(addr?.id) { mutableStateOf(addr?.label ?: "") } + var departmentId by rememberSaveable(addr?.id) { mutableStateOf(addr?.departmentId ?: data.departments.firstOrNull()?.id ?: 0) } + var priorityId by rememberSaveable(addr?.id) { mutableStateOf(addr?.priorityId) } + var isDefault by rememberSaveable(addr?.id) { mutableStateOf(addr?.isDefault ?: false) } + var deptExpanded by remember { mutableStateOf(false) } + var prioExpanded by remember { mutableStateOf(false) } + // Entferntes Postfach + var imapEnabled by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapEnabled ?: false) } + var imapHost by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapHost ?: "") } + var imapPort by rememberSaveable(addr?.id) { mutableStateOf((addr?.imapPort ?: 993).toString()) } + var imapEncryption by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapEncryption ?: "ssl") } + var imapUsername by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapUsername ?: "") } + var imapPassword by rememberSaveable(addr?.id) { mutableStateOf("") } + var imapFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapFolder ?: "INBOX") } + var imapAfterFetch by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapAfterFetch ?: "seen") } + var imapArchiveFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapArchiveFolder ?: "Processed") } + var imapSpamFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapSpamFolder ?: "Junk") } + // Konto: Status, Hilfethema, Notizen + var isActive by rememberSaveable(addr?.id) { mutableStateOf(addr?.isActive ?: true) } + var helpTopicId by rememberSaveable(addr?.id) { mutableStateOf(addr?.helpTopicId) } + var notes by rememberSaveable(addr?.id) { mutableStateOf(addr?.notes ?: "") } + var topicExpanded by remember { mutableStateOf(false) } + // Abgehend (eigener SMTP) + var smtpEnabled by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpEnabled ?: false) } + var smtpHost by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpHost ?: "") } + var smtpPort by rememberSaveable(addr?.id) { mutableStateOf((addr?.smtpPort ?: 587).toString()) } + var smtpEncryption by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpEncryption ?: "tls") } + var smtpUsername by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpUsername ?: "") } + var smtpPassword by rememberSaveable(addr?.id) { mutableStateOf("") } + var smtpEncExpanded by remember { mutableStateOf(false) } + val smtpEncLabels = listOf("tls" to "STARTTLS (587)", "ssl" to "SSL/TLS (465)", "none" to "Keine") + var encExpanded by remember { mutableStateOf(false) } + var afterExpanded by remember { mutableStateOf(false) } + val encLabels = listOf("ssl" to "SSL/TLS (993)", "tls" to "STARTTLS (143)", "none" to "Keine") + val afterLabels = listOf("seen" to "Als gelesen markieren", "archive" to "In Ordner verschieben", "delete" to "Löschen") + + Column(Modifier.padding(padding).fillMaxSize()) { + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Konto") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Entferntes Postfach") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Abgehend (SMTP)") }) + } + Column( + modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(if (addr == null) "Neue E-Mail-Adresse" else "E-Mail-Adresse bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (tab == 0) { + OutlinedTextField(value = email, onValueChange = { email = it }, modifier = Modifier.fillMaxWidth(), label = { Text("E-Mail-Adresse") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)) + OutlinedTextField(value = label, onValueChange = { label = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Anzeigename (optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + Text("Status", style = MaterialTheme.typography.labelLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = isActive, onClick = { isActive = true }) + Text("Aktiv", Modifier.clickable { isActive = true }) + Spacer(Modifier.width(16.dp)) + RadioButton(selected = !isActive, onClick = { isActive = false }) + Text("Deaktiviert", Modifier.clickable { isActive = false }) + } + Text("Einstellungen bei neuem Ticket", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + SelectionField(label = "Abteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) { + data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; deptExpanded = false }) } + } + SelectionField(label = "Priorität", value = data.priorities.firstOrNull { it.id == priorityId }?.name ?: "Systemstandard", expanded = prioExpanded, onExpand = { prioExpanded = true }, onDismiss = { prioExpanded = false }) { + DropdownMenuItem(text = { Text("Systemstandard") }, onClick = { priorityId = null; prioExpanded = false }) + data.priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { priorityId = p.id; prioExpanded = false }) } + } + SelectionField(label = "Hilfethema", value = data.helpTopics.firstOrNull { it.id == helpTopicId }?.name ?: "Systemstandard", expanded = topicExpanded, onExpand = { topicExpanded = true }, onDismiss = { topicExpanded = false }) { + DropdownMenuItem(text = { Text("Systemstandard") }, onClick = { helpTopicId = null; topicExpanded = false }) + data.helpTopics.forEach { t -> DropdownMenuItem(text = { Text(t.name) }, onClick = { helpTopicId = t.id; topicExpanded = false }) } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = isDefault, onCheckedChange = { isDefault = it }) + Text("Standard-Absenderadresse dieser Abteilung") + } + OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Interne Notizen (vertraulich)") }, minLines = 3, shape = MaterialTheme.shapes.medium) + } else if (tab == 2) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = smtpEnabled, onCheckedChange = { smtpEnabled = it }) + Text("Eigenen SMTP-Server für diese Adresse verwenden") + } + Text("Ohne eigenen Server wird der globale SMTP aus den Einstellungen genutzt.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (smtpEnabled) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField(value = smtpHost, onValueChange = { smtpHost = it }, modifier = Modifier.weight(1f), label = { Text("Server") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = smtpPort, onValueChange = { smtpPort = it.filter(Char::isDigit) }, modifier = Modifier.width(100.dp), label = { Text("Port") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + OutlinedTextField(value = smtpUsername, onValueChange = { smtpUsername = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField( + value = smtpPassword, onValueChange = { smtpPassword = it }, modifier = Modifier.fillMaxWidth(), + label = { Text(if (addr?.smtpHasPassword == true) "Passwort (leer = unverändert)" else "Passwort") }, + singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(), + ) + SelectionField(label = "Verschlüsselung", value = smtpEncLabels.firstOrNull { it.first == smtpEncryption }?.second ?: smtpEncryption, expanded = smtpEncExpanded, onExpand = { smtpEncExpanded = true }, onDismiss = { smtpEncExpanded = false }) { + smtpEncLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { smtpEncryption = v; smtpEncExpanded = false }) } + } + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = imapEnabled, onCheckedChange = { imapEnabled = it }) + Text("Abruf für diese Adresse aktivieren") + } + Text( + "Abteilung und Priorität stammen aus dem Reiter „Konto“." + (addr?.imapLastRunAt?.let { " Letzter Abruf: $it." } ?: ""), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (imapEnabled) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField(value = imapHost, onValueChange = { imapHost = it }, modifier = Modifier.weight(1f), label = { Text("Server") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = imapPort, onValueChange = { imapPort = it.filter(Char::isDigit) }, modifier = Modifier.width(100.dp), label = { Text("Port") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + OutlinedTextField(value = imapUsername, onValueChange = { imapUsername = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername (leer = E-Mail-Adresse)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField( + value = imapPassword, onValueChange = { imapPassword = it }, modifier = Modifier.fillMaxWidth(), + label = { Text(if (addr?.imapHasPassword == true) "Passwort (leer = unverändert)" else "Passwort") }, + singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + SelectionField(label = "Verschlüsselung", value = encLabels.firstOrNull { it.first == imapEncryption }?.second ?: imapEncryption, expanded = encExpanded, onExpand = { encExpanded = true }, onDismiss = { encExpanded = false }) { + encLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { imapEncryption = v; encExpanded = false }) } + } + OutlinedTextField(value = imapFolder, onValueChange = { imapFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Ordner") }, singleLine = true, shape = MaterialTheme.shapes.medium) + SelectionField(label = "Aktion nach Abruf", value = afterLabels.firstOrNull { it.first == imapAfterFetch }?.second ?: imapAfterFetch, expanded = afterExpanded, onExpand = { afterExpanded = true }, onDismiss = { afterExpanded = false }) { + afterLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { imapAfterFetch = v; afterExpanded = false }) } + } + if (imapAfterFetch == "archive") { + OutlinedTextField(value = imapArchiveFolder, onValueChange = { imapArchiveFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Zielordner") }, singleLine = true, shape = MaterialTheme.shapes.medium) + } + OutlinedTextField(value = imapSpamFolder, onValueChange = { imapSpamFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Spam-/Junk-Ordner") }, singleLine = true, shape = MaterialTheme.shapes.medium) + } + } + state.emailAddressesNotice?.let { Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium) } + state.emailAddressesMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + onSave( + addr?.id, + EmailAddressInput( + email = email.trim(), label = label.trim().ifBlank { null }, departmentId = departmentId, priorityId = priorityId, isDefault = isDefault, + helpTopicId = helpTopicId, isActive = isActive, notes = notes.trim(), + smtpEnabled = smtpEnabled, smtpHost = smtpHost.trim(), smtpPort = smtpPort.toIntOrNull() ?: 587, smtpEncryption = smtpEncryption, + smtpUsername = smtpUsername.trim(), smtpPassword = smtpPassword, + imapEnabled = imapEnabled, imapHost = imapHost.trim(), imapPort = imapPort.toIntOrNull() ?: 993, imapEncryption = imapEncryption, + imapUsername = imapUsername.trim(), imapPassword = imapPassword, imapFolder = imapFolder.trim(), imapAfterFetch = imapAfterFetch, + imapArchiveFolder = imapArchiveFolder.trim(), imapSpamFolder = imapSpamFolder.trim(), + ), + ) { editingId = null; isCreating = false } + }, + shape = MaterialTheme.shapes.medium, + enabled = !state.emailAddressesMutationLoading && email.isNotBlank() && departmentId != 0, + ) { Text(if (state.emailAddressesMutationLoading) "Bitte warten …" else "Speichern") } + OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + if (addr != null) { + Text("Tests verwenden die gespeicherten Werte.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = { onTest(addr.id, "sendTest") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("Test-Mail senden") } + if (addr.imapEnabled) { + OutlinedButton(onClick = { onTest(addr.id, "testImap") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("Postfach testen") } + } + if (addr.smtpEnabled) { + OutlinedButton(onClick = { onTest(addr.id, "testSmtp") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("SMTP testen") } + } + } + } + } + } + } + data.addresses.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine E-Mail-Adressen angelegt.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(data.addresses) { addr -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(addr.email, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + if (!addr.isActive) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) { + Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.width(6.dp)) + } + if (addr.imapEnabled) { + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.extraSmall) { + Text("Abruf", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.width(6.dp)) + } + if (addr.isDefault) { + Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) { + Text("Standard", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + } + } + Text("${addr.label?.let { "$it · " } ?: ""}${addr.departmentName}${addr.priorityName?.let { " · $it" } ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp)) { + TextButton(onClick = { editingId = addr.id }) { Text("Bearbeiten") } + TextButton(onClick = { onDelete(addr.id) }) { Text("Löschen") } + } + } + } + } + } + } + } +} + +// ─── Admin: E-Mail-Vorlagen ────────────────────────────────────────────────── + +@Composable +private fun AdminEmailTemplatesScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: () -> Unit, + onSave: (String, String, String) -> Unit, + onReset: (String) -> Unit, +) { + LaunchedEffect(Unit) { onLoad() } + val items = state.emailTemplates + + Scaffold(topBar = { AdminTopBar("E-Mail-Vorlagen", onBack) }) { padding -> + when { + state.emailTemplatesLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Vorlagen werden geladen …") } + state.emailTemplatesError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.emailTemplatesError, onLoad) } + items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + Text( + "Der Branding-Rahmen (Kopf-/Fußzeile) wird beim Versand automatisch ergänzt – hier nur den Inhalt anpassen.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + items(items, key = { it.key }) { tpl -> + var subject by rememberSaveable(tpl.key, tpl.subject) { mutableStateOf(tpl.subject) } + var body by rememberSaveable(tpl.key, tpl.body) { mutableStateOf(tpl.body) } + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(tpl.label, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + if (tpl.isCustomized) { + Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) { + Text("angepasst", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall) + } + } + } + Text( + "Platzhalter: " + tpl.placeholders.joinToString(", ") { "{$it}" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField(value = subject, onValueChange = { subject = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Betreff") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = body, onValueChange = { body = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Text (HTML erlaubt)") }, minLines = 5, shape = MaterialTheme.shapes.medium) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { onSave(tpl.key, subject.trim(), body.trim()) }, + shape = MaterialTheme.shapes.medium, + enabled = !state.emailTemplatesMutationLoading && subject.isNotBlank() && body.isNotBlank(), + ) { Text("Speichern") } + if (tpl.isCustomized) { + OutlinedButton(onClick = { onReset(tpl.key) }, shape = MaterialTheme.shapes.medium) { Text("Zurücksetzen") } + } + } + } + } + } + state.emailTemplatesMutationError?.let { err -> item { ErrorText(err) } } + } + } + } +} + +// ─── Admin: Admin-Protokoll ────────────────────────────────────────────────── + +@Composable +private fun AdminAuditLogScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: (Boolean) -> Unit, +) { + LaunchedEffect(Unit) { onLoad(true) } + + Scaffold(topBar = { AdminTopBar("Admin-Protokoll", onBack) }) { padding -> + when { + state.auditLogLoading && state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { LoadingState("Protokoll wird geladen …") } + state.auditLogError != null && state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { ErrorState(state.auditLogError) { onLoad(true) } } + state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Einträge.") } + else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.auditLog, key = AuditLogEntry::id) { entry -> + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(12.dp)) { + Text(entry.action, style = MaterialTheme.typography.bodyMedium) + Text( + "${entry.staffName ?: "System"} · ${entry.createdAt}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + if (state.auditLogHasMore) { + item { + OutlinedButton( + onClick = { onLoad(false) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + enabled = !state.auditLogLoading, + ) { Text(if (state.auditLogLoading) "Lädt …" else "Mehr laden") } + } + } + } + } + } +} + +// ─── Aufgaben ──────────────────────────────────────────────────────────────── + +private val TaskStatusLabels = linkedMapOf("open" to "Offen", "in_progress" to "In Arbeit", "done" to "Erledigt") + +@Composable +private fun AdminTasksScreen( + state: DeskHubState, + onBack: () -> Unit, + onLoad: (String) -> Unit, + onCreate: (String, String?, Int?, String?, String?, () -> Unit) -> Unit, + onUpdateStatus: (Int, String) -> Unit, + onDelete: (Int) -> Unit, + onClearError: () -> Unit, +) { + LaunchedEffect(Unit) { onLoad(state.tasksFilter) } + var isCreating by rememberSaveable { mutableStateOf(false) } + val data = state.tasks + + Scaffold( + topBar = { AdminTopBar("Aufgaben", onBack) }, + floatingActionButton = { + if (data != null && !isCreating) { + FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Aufgabe") } + } + }, + ) { padding -> + when { + state.tasksLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Aufgaben werden geladen …") } + state.tasksError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.tasksError) { onLoad(state.tasksFilter) } } + data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + isCreating -> { + var title by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + var assignedToId by rememberSaveable { mutableStateOf(null) } + var dueAt by rememberSaveable { mutableStateOf("") } + var ticketNumber by rememberSaveable { mutableStateOf("") } + var assignExpanded by remember { mutableStateOf(false) } + Column( + modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Neue Aufgabe", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField(value = title, onValueChange = { title = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Titel") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = description, onValueChange = { description = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Beschreibung (optional)") }, minLines = 3, shape = MaterialTheme.shapes.medium) + SelectionField(label = "Zuweisen an", value = data.staffOptions.firstOrNull { it.id == assignedToId }?.name ?: "Nicht zugewiesen", expanded = assignExpanded, onExpand = { assignExpanded = true }, onDismiss = { assignExpanded = false }) { + DropdownMenuItem(text = { Text("Nicht zugewiesen") }, onClick = { assignedToId = null; assignExpanded = false }) + data.staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { assignedToId = s.id; assignExpanded = false }) } + } + OutlinedTextField(value = dueAt, onValueChange = { dueAt = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Fällig bis (JJJJ-MM-TT HH:MM, optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + OutlinedTextField(value = ticketNumber, onValueChange = { ticketNumber = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Ticketnummer verknüpfen (optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium) + state.tasksMutationError?.let { ErrorText(it) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + onCreate(title.trim(), description.trim().ifBlank { null }, assignedToId, dueAt.trim().ifBlank { null }.let { it?.replace(" ", "T") }, ticketNumber.trim().ifBlank { null }) { + isCreating = false + } + }, + shape = MaterialTheme.shapes.medium, + enabled = !state.tasksMutationLoading && title.isNotBlank(), + ) { Text(if (state.tasksMutationLoading) "Wird erstellt …" else "Erstellen") } + OutlinedButton(onClick = { isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") } + } + } + } + else -> Column(Modifier.padding(padding).fillMaxSize()) { + ScrollableTabRow(selectedTabIndex = listOf("my", "all", "open", "done").indexOf(state.tasksFilter).coerceAtLeast(0), edgePadding = 12.dp) { + listOf("my" to "Meine", "all" to "Alle", "open" to "Offen", "done" to "Erledigt").forEach { (value, label) -> + Tab(selected = state.tasksFilter == value, onClick = { onLoad(value) }, text = { Text(label) }) + } + } + if (data.tasks.isEmpty()) { + EmptyState("Keine Aufgaben gefunden.") + } else { + LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(data.tasks, key = StaffTask::id) { task -> TaskRowAdmin(task, onUpdateStatus = { onUpdateStatus(task.id, it) }, onDelete = { onDelete(task.id) }) } + } + } + } + } + } +} + +@Composable +private fun TaskRowAdmin(task: StaffTask, onUpdateStatus: (String) -> Unit, onDelete: () -> Unit) { + var statusExpanded by remember { mutableStateOf(false) } + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(14.dp)) { + Text( + task.title, + fontWeight = FontWeight.SemiBold, + textDecoration = if (task.status == "done") androidx.compose.ui.text.style.TextDecoration.LineThrough else null, + ) + task.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (task.ticketNumber != null) { + Text("Ticket #${task.ticketNumber}: ${task.ticketSubject ?: ""}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + Text( + buildString { + append(task.assignedName ?: "Nicht zugewiesen") + task.dueAt?.let { append(" · fällig $it") } + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + SelectionField( + label = "Status", value = TaskStatusLabels[task.status] ?: task.status, + expanded = statusExpanded, onExpand = { statusExpanded = true }, onDismiss = { statusExpanded = false }, + ) { + TaskStatusLabels.forEach { (value, label) -> DropdownMenuItem(text = { Text(label) }, onClick = { onUpdateStatus(value); statusExpanded = false }) } + } + Spacer(Modifier.width(10.dp)) + TextButton(onClick = onDelete) { Text("Löschen") } + } + } + } +} + +// ─── Customers & new ticket ───────────────────────────────────────────────── + +@Composable +private fun CustomersScreen( + state: DeskHubState, + onSearch: (String) -> Unit, + onCustomer: (de.deskhub.staff.domain.model.Customer) -> Unit, +) { + var query by rememberSaveable { mutableStateOf(state.customersQuery) } + LaunchedEffect(Unit) { if (state.customers.isEmpty()) onSearch(query) } + Scaffold( + topBar = { + TopAppBar( + title = { Text("Kunden", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + ), + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + label = { Text("Name oder E-Mail") }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch(query) }), + ) + when { + state.customersLoading && state.customers.isEmpty() -> LoadingState("Kunden werden geladen …") + state.customersError != null && state.customers.isEmpty() -> + ErrorState(state.customersError) { onSearch(query) } + state.customers.isEmpty() -> EmptyState("Keine Kunden gefunden.") + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.customers, key = { "${it.isGuest}-${it.id}-${it.email}" }) { customer -> + CustomerRow(customer, onCustomer) + } + } + } + } + } +} + +@Composable +private fun CustomerRow(customer: de.deskhub.staff.domain.model.Customer, onClick: (de.deskhub.staff.domain.model.Customer) -> Unit) { + Card(onClick = { onClick(customer) }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Row( + modifier = Modifier + .padding(14.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f)) { + Text(customer.name, fontWeight = FontWeight.SemiBold) + Text( + customer.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + customer.organizationName?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Column(horizontalAlignment = Alignment.End) { + Surface( + color = if (customer.isGuest) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer, + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + if (customer.isGuest) "Gast" else "Konto", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = if (customer.isGuest) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onPrimaryContainer + }, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + "${customer.ticketCount} Tickets", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun CustomerProfileScreen( + state: DeskHubState, + onBack: () -> Unit, + onTicket: (String) -> Unit, + onNewTicket: (String, String) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(state.selectedCustomer?.customer?.name ?: "Kunde", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + ) + }, + ) { padding -> + val profile = state.selectedCustomer + when { + state.customerLoading && profile == null -> + Box(Modifier.padding(padding)) { LoadingState("Kundenprofil wird geladen …") } + state.customerError != null && profile == null -> + Box(Modifier.padding(padding)) { ErrorState(state.customerError, onBack) } + profile != null -> LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + profile.customer.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text(profile.customer.email, color = MaterialTheme.colorScheme.onPrimaryContainer) + profile.customer.organizationName?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), + ) + } + Text( + if (profile.customer.isGuest) "Gast-Kunde" else "Registriertes Konto", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), + ) + } + } + } + item { + Button( + onClick = { onNewTicket(profile.customer.name, profile.customer.email) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Neues Ticket für diesen Kunden") + } + } + item { + Text( + "Tickets (${profile.tickets.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + if (profile.tickets.isEmpty()) { + item { EmptyState("Keine Tickets vorhanden.") } + } else { + items(profile.tickets, key = TicketSummary::id) { ticket -> TicketCard(ticket, state.metadata, onTicket) } + } + } + else -> Box(Modifier.padding(padding)) { EmptyState("Kein Kunde ausgewählt.") } + } + } +} + +@Composable +private fun NewTicketScreen( + state: DeskHubState, + onBack: () -> Unit, + onCreate: (String, String, String, String, Int, Int, Int?, Boolean) -> Unit, +) { + var name by rememberSaveable { mutableStateOf(state.newTicketPrefillName) } + var email by rememberSaveable { mutableStateOf(state.newTicketPrefillEmail) } + var subject by rememberSaveable { mutableStateOf("") } + var message by rememberSaveable { mutableStateOf("") } + val departments = state.metadata?.departments.orEmpty() + val priorities = state.metadata?.priorities.orEmpty() + var departmentId by rememberSaveable { mutableStateOf(departments.firstOrNull()?.id ?: 0) } + var priorityId by rememberSaveable { mutableStateOf(priorities.firstOrNull()?.id ?: 0) } + var assignToMe by rememberSaveable { mutableStateOf(true) } + var notifyCustomer by rememberSaveable { mutableStateOf(true) } + var departmentExpanded by remember { mutableStateOf(false) } + var priorityExpanded by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Neues Ticket", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Name des Kunden") }, + leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("E-Mail (optional)") }, + leadingIcon = { Icon(Icons.Filled.Email, contentDescription = null) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + ) + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Betreff") }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + SelectionField( + label = "Abteilung", + value = departments.firstOrNull { it.id == departmentId }?.name ?: "–", + expanded = departmentExpanded, + onExpand = { departmentExpanded = true }, + onDismiss = { departmentExpanded = false }, + ) { + departments.forEach { department -> + DropdownMenuItem( + text = { Text(department.name) }, + onClick = { departmentId = department.id; departmentExpanded = false }, + ) + } + } + SelectionField( + label = "Priorität", + value = priorities.firstOrNull { it.id == priorityId }?.name ?: "–", + expanded = priorityExpanded, + onExpand = { priorityExpanded = true }, + onDismiss = { priorityExpanded = false }, + ) { + priorities.forEach { priority -> + DropdownMenuItem( + text = { Text(priority.name) }, + onClick = { priorityId = priority.id; priorityExpanded = false }, + ) + } + } + OutlinedTextField( + value = message, + onValueChange = { message = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Beschreibung") }, + shape = MaterialTheme.shapes.medium, + minLines = 5, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = assignToMe, onCheckedChange = { assignToMe = it }) + Text("Mir zuweisen") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = notifyCustomer, onCheckedChange = { notifyCustomer = it }, enabled = email.isNotBlank()) + Text("Eingangsbestätigung per E-Mail senden") + } + state.newTicketError?.let { ErrorText(it) } + Button( + onClick = { + onCreate( + name.trim(), + email.trim(), + subject.trim(), + message.trim(), + departmentId, + priorityId, + if (assignToMe) state.staff?.id else null, + notifyCustomer && email.isNotBlank(), + ) + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = MaterialTheme.shapes.medium, + enabled = !state.newTicketLoading && name.isNotBlank() && subject.isNotBlank() && + message.isNotBlank() && departmentId != 0 && priorityId != 0, + ) { + Text(if (state.newTicketLoading) "Wird erstellt …" else "Ticket erstellen") + } + } + } +} + +// ─── Ticket detail ────────────────────────────────────────────────────────── + +@Composable +private fun TicketDetailScreen( + state: DeskHubState, + onBack: () -> Unit, + onRetry: () -> Unit, + onUpdate: (String, Int?) -> Unit, + onMessage: (String, String, String?, () -> Unit) -> Unit, + onSpamAction: (Boolean) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(state.selectedTicket?.summary?.let { "#${it.number}" } ?: "Ticket", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = LocalChrome.current, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + ), + ) + }, + ) { padding -> + when { + state.detailLoading && state.selectedTicket == null -> + Box(Modifier.padding(padding)) { LoadingState("Ticket wird geladen …") } + state.detailError != null && state.selectedTicket == null -> + Box(Modifier.padding(padding)) { ErrorState(state.detailError, onRetry) } + state.selectedTicket != null -> TicketDetailContent( + modifier = Modifier.padding(padding), + ticket = state.selectedTicket, + metadata = state.metadata, + cannedResponses = state.cannedResponses, + mutationLoading = state.mutationLoading, + mutationError = state.mutationError, + onUpdate = onUpdate, + onMessage = onMessage, + onSpamAction = onSpamAction, + ) + else -> Box(Modifier.padding(padding)) { EmptyState("Kein Ticket ausgewählt.") } + } + } +} + +@Composable +private fun TicketDetailContent( + modifier: Modifier, + ticket: TicketDetail, + metadata: Metadata?, + cannedResponses: List, + mutationLoading: Boolean, + mutationError: String?, + onUpdate: (String, Int?) -> Unit, + onMessage: (String, String, String?, () -> Unit) -> Unit, + onSpamAction: (Boolean) -> Unit, +) { + var selectedStatus by remember(ticket.summary.number) { mutableStateOf(ticket.summary.status) } + var selectedAgent by remember(ticket.summary.number) { mutableStateOf(ticket.summary.assignedStaff?.id) } + var statusExpanded by remember { mutableStateOf(false) } + var agentExpanded by remember { mutableStateOf(false) } + var messageType by rememberSaveable(ticket.summary.number) { mutableStateOf("reply") } + var message by rememberSaveable(ticket.summary.number) { mutableStateOf("") } + var replyStatus by rememberSaveable(ticket.summary.number) { mutableStateOf("answered") } + // Rechte für dieses Ticket (gelten pro Abteilung); ältere Server liefern sie nicht -> globale Rechte + fun ticketAllowed(permission: String) = ticketPermission(ticket.permissions, metadata?.staff?.permissions, permission) + val canReply = ticketAllowed("reply_ticket") + val canNote = ticketAllowed("post_note") + val canAssign = ticketAllowed("assign_ticket") + val canClose = ticketAllowed("close_ticket") + val statusChoices = metadata?.statuses.orEmpty().filter { status -> + if (status.value in setOf("resolved", "closed")) canClose else canReply + } + val canChangeStatus = statusChoices.isNotEmpty() + val priorityColor = parseHexColor(ticket.summary.priority.color, MaterialTheme.colorScheme.outline) + // Live-Chat-Sperre: ein anderer Mitarbeiter (Web oder App) bearbeitet diesen Chat + // gerade aktiv. Lesen bleibt erlaubt, nur öffentliche Antworten werden blockiert – + // interne Notizen bleiben immer möglich (siehe api_add_ticket_message() im Backend). + val chatLockedByOther = ticket.source == "chat" && ticket.lockedByOtherName != null + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + "#${ticket.summary.number}", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (ticket.summary.isSpam) { + Surface(color = Color(0xFFDC2626).copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) { + Text( + "🚫 SPAM", + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = Color(0xFFDC2626), + fontWeight = FontWeight.SemiBold, + ) + } + } + StatusPill(ticket.summary.status, metadata) + } + } + Text( + ticket.summary.subject, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Person, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.width(6.dp)) + Text( + "${ticket.summary.requesterName} · ${ticket.summary.requesterEmail}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(priorityColor), + ) + Spacer(Modifier.width(6.dp)) + Text( + "${ticket.summary.department.name} · ${ticket.summary.priority.name} · ${ticketSourceLabel(ticket.source)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), + ) + } + ticket.resolutionDueAt?.let { due -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Schedule, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.width(6.dp)) + Text( + "Lösung fällig: $due", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } + } + if (ticket.source == "email") { + item { + if (!ticket.summary.isSpam) { + OutlinedButton( + onClick = { onSpamAction(false) }, + enabled = !mutationLoading, + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFDC2626)), + ) { Text("🚫 Als Spam markieren") } + } else { + OutlinedButton( + onClick = { onSpamAction(true) }, + enabled = !mutationLoading, + modifier = Modifier.fillMaxWidth(), + ) { Text("↩️ Spam-Markierung aufheben") } + } + } + } + item { + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Bearbeitung", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + SelectionField( + label = "Status", + value = metadata?.statuses?.firstOrNull { it.value == selectedStatus }?.label ?: selectedStatus, + expanded = statusExpanded, + enabled = canChangeStatus, + onExpand = { statusExpanded = true }, + onDismiss = { statusExpanded = false }, + ) { + statusChoices.forEach { status -> + DropdownMenuItem( + text = { Text(status.label) }, + onClick = { + selectedStatus = status.value + statusExpanded = false + }, + ) + } + } + SelectionField( + label = "Zuständig", + value = metadata?.agents?.firstOrNull { it.id == selectedAgent }?.name ?: "Nicht zugewiesen", + expanded = agentExpanded, + enabled = canAssign, + onExpand = { agentExpanded = true }, + onDismiss = { agentExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("Nicht zugewiesen") }, + onClick = { + selectedAgent = null + agentExpanded = false + }, + ) + metadata?.agents?.forEach { agent -> + DropdownMenuItem( + text = { Text(agent.name) }, + onClick = { + selectedAgent = agent.id + agentExpanded = false + }, + ) + } + } + Button( + onClick = { onUpdate(selectedStatus, selectedAgent) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + enabled = !mutationLoading && + (selectedStatus != ticket.summary.status || selectedAgent != ticket.summary.assignedStaff?.id), + ) { + Text(if (mutationLoading) "Speichert …" else "Änderungen speichern") + } + } + } + } + item { + Text("Verlauf", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + if (ticket.threads.isEmpty()) { + item { EmptyState("Noch keine Nachrichten vorhanden.") } + } else { + items(ticket.threads, key = { it.id }) { thread -> ThreadBubble(thread) } + } + item { + // Ist der Chat gerade woanders in Bearbeitung, auf "Interne Notiz" ausweichen statt + // auf dem gesperrten "Antwort"-Tab hängen zu bleiben (falls erlaubt). + LaunchedEffect(chatLockedByOther, canNote) { + if (chatLockedByOther && messageType == "reply" && canNote) messageType = "note" + } + Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Nachricht verfassen", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (chatLockedByOther) { + Surface(color = Color(0xFFFFFBEB), shape = MaterialTheme.shapes.small) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("🔒", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.width(8.dp)) + Text( + "Wird gerade von ${ticket.lockedByOtherName} bearbeitet – Sie sehen live mit, können hier aber nicht antworten.", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF92400E), + ) + } + } + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = messageType == "reply", + onClick = { messageType = "reply" }, + enabled = canReply && !chatLockedByOther, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { Text("Antwort") } + SegmentedButton( + selected = messageType == "note", + onClick = { messageType = "note" }, + enabled = canNote, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { Text("Interne Notiz") } + } + if (cannedResponses.isNotEmpty() && (canReply || canNote)) { + var templatesExpanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { templatesExpanded = true }) { + Icon(Icons.Filled.Description, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Vorlage einfügen") + } + DropdownMenu(expanded = templatesExpanded, onDismissRequest = { templatesExpanded = false }) { + cannedResponses.forEach { canned -> + DropdownMenuItem( + text = { Text(canned.title) }, + onClick = { + message = canned.body + templatesExpanded = false + }, + ) + } + } + } + } + OutlinedTextField( + value = message, + onValueChange = { message = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(if (messageType == "note") "Interne Notiz" else "Antwort an Kunden") }, + shape = MaterialTheme.shapes.medium, + minLines = 4, + enabled = !(chatLockedByOther && messageType == "reply"), + ) + if (messageType == "reply") { + var replyStatusExpanded by remember { mutableStateOf(false) } + SelectionField( + label = "Status nach Antwort", + value = metadata?.statuses?.firstOrNull { it.value == replyStatus }?.label ?: replyStatus, + expanded = replyStatusExpanded, + onExpand = { replyStatusExpanded = true }, + onDismiss = { replyStatusExpanded = false }, + ) { + statusChoices.forEach { status -> + DropdownMenuItem( + text = { Text(status.label) }, + onClick = { + replyStatus = status.value + replyStatusExpanded = false + }, + ) + } + } + } + mutationError?.let { ErrorText(it) } + Button( + onClick = { + onMessage( + messageType, + message, + replyStatus.takeIf { messageType == "reply" }, + ) { message = "" } + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = MaterialTheme.shapes.medium, + enabled = !mutationLoading && message.isNotBlank() && + ((messageType == "reply" && canReply && !chatLockedByOther) || (messageType == "note" && canNote)), + ) { + if (!mutationLoading) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + } + Text(if (mutationLoading) "Sendet …" else if (messageType == "note") "Notiz speichern" else "Antwort senden") + } + } + } + } + } +} + +@Composable +private fun ThreadBubble(thread: de.deskhub.staff.domain.model.TicketThread) { + val isStaff = thread.authorType == "staff" + val isSystem = thread.authorType == "system" + + if (isSystem) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) { + Text( + thread.message, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + val bubbleColor = when { + thread.isInternalNote -> MaterialTheme.colorScheme.secondaryContainer + isStaff -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + val onBubbleColor = when { + thread.isInternalNote -> MaterialTheme.colorScheme.onSecondaryContainer + isStaff -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val bubbleShape = if (isStaff) { + RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 4.dp) + } else { + RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start, + ) { + Column(modifier = Modifier.fillMaxWidth(0.86f)) { + if (thread.isInternalNote) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start, + ) { + Icon( + Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + Spacer(Modifier.width(4.dp)) + Text( + "Interne Notiz", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(2.dp)) + } + Surface(color = bubbleColor, shape = bubbleShape) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(thread.authorName, fontWeight = FontWeight.Bold, color = onBubbleColor, style = MaterialTheme.typography.bodySmall) + Text(thread.createdAt, style = MaterialTheme.typography.labelSmall, color = onBubbleColor.copy(alpha = 0.7f)) + } + Text(thread.message, color = onBubbleColor, style = MaterialTheme.typography.bodyMedium) + thread.attachments.forEach { attachment -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.AttachFile, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = onBubbleColor, + ) + Spacer(Modifier.width(4.dp)) + Text( + "${attachment.name} (${attachment.size / 1024} KB)", + style = MaterialTheme.typography.labelSmall, + color = onBubbleColor, + ) + } + } + thread.comments.forEach { comment -> + HorizontalDivider(color = onBubbleColor.copy(alpha = 0.15f)) + Text( + "${comment.staff.name}: ${comment.message}", + style = MaterialTheme.typography.bodySmall, + color = onBubbleColor, + ) + } + } + } + } + } +} + +/** + * Mehrzeiliges Signatur-Feld mit "Bild einfügen"-Button: lädt ein per Bildauswahl gewähltes + * Foto hoch und hängt bei Erfolg ein -Tag an den (reinen) HTML-Signaturtext an – analog + * zum 🖼️-Knopf im Web-Editor (assets/js/richtext.js). + */ +@Composable +private fun SignatureField( + value: String, + onValueChange: (String) -> Unit, + uploading: Boolean, + uploadError: String?, + onUpload: (ByteArray, String, String, (String) -> Unit) -> Unit, + minLines: Int = 3, +) { + val context = LocalContext.current + val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + val resolver = context.contentResolver + val mimeType = resolver.getType(uri) ?: "image/jpeg" + val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() + if (bytes != null) { + val ext = when { + mimeType.contains("png") -> "png" + mimeType.contains("gif") -> "gif" + else -> "jpg" + } + onUpload(bytes, "signature.$ext", mimeType) { url -> + val insertion = "" + onValueChange(if (value.isBlank()) insertion else "$value\n$insertion") + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Signatur (optional)") }, + minLines = minLines, + shape = MaterialTheme.shapes.medium, + ) + TextButton(onClick = { pickImage.launch("image/*") }, enabled = !uploading) { + Text(if (uploading) "Bild wird hochgeladen …" else "🖼️ Bild einfügen") + } + uploadError?.let { ErrorText(it) } + } +} + +@Composable +private fun SelectionField( + label: String, + value: String, + expanded: Boolean, + enabled: Boolean = true, + onExpand: () -> Unit, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + Box { + OutlinedButton( + onClick = onExpand, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + enabled = enabled, + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("$label: $value") + Text("▾") + } + } + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + content() + } + } +} + +@Composable +internal fun StatusPill(status: String, metadata: Metadata?) { + val option = metadata?.statuses?.firstOrNull { it.value == status } + val color = parseHexColor(option?.color, Color(0xFF64748B)) + Surface(color = color.copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) { + Text( + option?.label ?: status, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = color, + fontWeight = FontWeight.SemiBold, + ) + } +} + +private fun parseHexColor(hex: String?, fallback: Color): Color = + runCatching { Color(parseColor(hex ?: return fallback)) }.getOrDefault(fallback) + +/** Spiegelt die Beschriftungen aus staff/ticket_view.php ($sourceLabels) für die App. */ +private fun ticketSourceLabel(source: String): String = when (source) { + "web" -> "🌐 Web" + "email" -> "📧 E-Mail" + "phone" -> "📞 Telefon" + "staff" -> "🖥 Intern" + "chat" -> "💬 Live-Chat" + else -> source +} + +@Composable +private fun LoadingState(message: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(Modifier.height(12.dp)) + Text(message) + } + } +} + +@Composable +private fun EmptyState(message: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun ErrorState(message: String, retry: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Filled.ErrorOutline, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + ErrorText(message) + Spacer(Modifier.height(12.dp)) + Button(onClick = retry, shape = MaterialTheme.shapes.medium) { Text("Erneut versuchen") } + } + } +} + +@Composable +private fun ErrorText(message: String, modifier: Modifier = Modifier) { + Text(message, modifier = modifier, color = MaterialTheme.colorScheme.error) +} diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt new file mode 100644 index 00000000..bd2ee40f --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt @@ -0,0 +1,1601 @@ +package de.deskhub.staff.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import de.deskhub.staff.data.LoginResult +import de.deskhub.staff.data.TicketRepository +import de.deskhub.staff.data.remote.ApiException +import de.deskhub.staff.domain.model.ActivityUpdate +import de.deskhub.staff.domain.model.AuditLogEntry +import de.deskhub.staff.domain.model.BanEntry +import de.deskhub.staff.domain.model.BusinessHour +import de.deskhub.staff.domain.model.BusinessHoursData +import de.deskhub.staff.domain.model.CannedResponse +import de.deskhub.staff.domain.model.ChatContact +import de.deskhub.staff.domain.model.ChatConversation +import de.deskhub.staff.domain.model.Customer +import de.deskhub.staff.domain.model.CustomerProfile +import de.deskhub.staff.domain.model.DashboardStats +import de.deskhub.staff.domain.model.Department +import de.deskhub.staff.domain.model.DepartmentEmailOption +import de.deskhub.staff.domain.model.DepartmentInput +import de.deskhub.staff.domain.model.NamedOption +import de.deskhub.staff.domain.model.EmailAddressData +import de.deskhub.staff.domain.model.EmailAddressInput +import de.deskhub.staff.domain.model.EmailTemplate +import de.deskhub.staff.domain.model.EscalationData +import de.deskhub.staff.domain.model.HelpTopicData +import de.deskhub.staff.domain.model.HelpTopicInput +import de.deskhub.staff.domain.model.AvailabilityData +import de.deskhub.staff.domain.model.KbData +import de.deskhub.staff.domain.model.LiveChatDetail +import de.deskhub.staff.domain.model.LiveChatSummary +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.TicketRuleAction +import de.deskhub.staff.domain.model.TicketRuleCondition +import de.deskhub.staff.domain.model.TicketRulesData +import de.deskhub.staff.domain.model.Organization +import de.deskhub.staff.domain.model.PriorityFull +import de.deskhub.staff.domain.model.RoleData +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StaffAdminData +import de.deskhub.staff.domain.model.StaffAdminInput +import de.deskhub.staff.domain.model.StaffTask +import de.deskhub.staff.domain.model.TaskData +import de.deskhub.staff.domain.model.TeamData +import de.deskhub.staff.domain.model.TicketDetail +import de.deskhub.staff.domain.model.TicketFilters +import de.deskhub.staff.domain.model.TicketSummary +import de.deskhub.staff.domain.model.TimeEntriesReport +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +data class DeskHubState( + val initializing: Boolean = true, + val staff: Staff? = null, + val authLoading: Boolean = false, + val authError: String? = null, + val totpRequired: Boolean = false, + /** Zuletzt verwendete Server-Adresse – bleibt auch nach dem Ausloggen erhalten, um erneutes Eintippen zu ersparen. */ + val savedServerUrl: String? = null, + val metadata: Metadata? = null, + val cannedResponses: List = emptyList(), + val filters: TicketFilters = TicketFilters(), + val tickets: List = emptyList(), + val ticketsPage: Int = 1, + val ticketsTotal: Int = 0, + val ticketsHasMore: Boolean = false, + val ticketsLoading: Boolean = false, + val ticketsError: String? = null, + val selectedTicket: TicketDetail? = null, + val detailLoading: Boolean = false, + val detailError: String? = null, + val mutationLoading: Boolean = false, + val mutationError: String? = null, + val dashboard: DashboardStats? = null, + val dashboardLoading: Boolean = false, + val dashboardError: String? = null, + val customers: List = emptyList(), + val customersQuery: String = "", + val customersLoading: Boolean = false, + val customersError: String? = null, + val selectedCustomer: CustomerProfile? = null, + val customerLoading: Boolean = false, + val customerError: String? = null, + val newTicketPrefillName: String = "", + val newTicketPrefillEmail: String = "", + val newTicketLoading: Boolean = false, + val newTicketError: String? = null, + val activityBadgeCount: Int = 0, + val offline: Boolean = false, + val healthIssues: List = emptyList(), + val kb: KbData? = null, + val kbLoading: Boolean = false, + val kbError: String? = null, + val kbMutationLoading: Boolean = false, + val kbMutationError: String? = null, + val timeEntries: TimeEntriesReport? = null, + val timeEntriesLoading: Boolean = false, + val timeEntriesError: String? = null, + val timeEntriesStaffId: Int? = null, + val timeEntriesFrom: String = currentMonthStart(), + val timeEntriesTo: String = today(), + val escalation: EscalationData? = null, + val escalationLoading: Boolean = false, + val escalationError: String? = null, + val escalationMutationLoading: Boolean = false, + val escalationMutationError: String? = null, + val banlist: List? = null, + val banlistLoading: Boolean = false, + val banlistError: String? = null, + val banlistMutationLoading: Boolean = false, + val banlistMutationError: String? = null, + val banlistLastBulkResult: Pair? = null, + // Abteilungen + val departments2: List? = null, + val departmentEmailOptions: List = emptyList(), + val departmentStaffOptions: List = emptyList(), + val departments2Loading: Boolean = false, + val departments2Error: String? = null, + val departments2MutationLoading: Boolean = false, + val departments2MutationError: String? = null, + // Prioritäten + val report: de.deskhub.staff.data.remote.dto.ReportResponseDto? = null, + val reportConfig: de.deskhub.staff.data.remote.dto.ReportConfigDto = de.deskhub.staff.data.remote.dto.ReportConfigDto(), + val reportLoading: Boolean = false, + val reportError: String? = null, + val savedViews: List = emptyList(), + val savedViewError: String? = null, + val slaPlans: List? = null, + val slaPlansLoading: Boolean = false, + val slaPlansError: String? = null, + val slaPlansMutationLoading: Boolean = false, + val slaPlansMutationError: String? = null, + val departmentSlaPlans: List = emptyList(), + val prioritiesAdmin: List? = null, + val prioritiesAdminLoading: Boolean = false, + val prioritiesAdminError: String? = null, + val prioritiesAdminMutationLoading: Boolean = false, + val prioritiesAdminMutationError: String? = null, + // Hilfe-Themen + val helpTopics: HelpTopicData? = null, + val helpTopicsLoading: Boolean = false, + val helpTopicsError: String? = null, + val helpTopicsMutationLoading: Boolean = false, + val helpTopicsMutationError: String? = null, + // Organisationen + val organizations: List? = null, + val organizationsLoading: Boolean = false, + val organizationsError: String? = null, + val organizationsMutationLoading: Boolean = false, + val organizationsMutationError: String? = null, + // Teams + val teams: TeamData? = null, + val teamsLoading: Boolean = false, + val teamsError: String? = null, + val teamsMutationLoading: Boolean = false, + val teamsMutationError: String? = null, + // Rollen + val roles: RoleData? = null, + val rolesLoading: Boolean = false, + val rolesError: String? = null, + val rolesMutationLoading: Boolean = false, + val rolesMutationError: String? = null, + // Mitarbeiter + val staffAdmin: StaffAdminData? = null, + val staffAdminLoading: Boolean = false, + val staffAdminError: String? = null, + val staffAdminMutationLoading: Boolean = false, + val staffAdminMutationError: String? = null, + // Geschäftszeiten + val businessHours: BusinessHoursData? = null, + val businessHoursLoading: Boolean = false, + val businessHoursError: String? = null, + val businessHoursMutationLoading: Boolean = false, + val businessHoursMutationError: String? = null, + // E-Mail-Adressen + val emailAddresses: EmailAddressData? = null, + val emailAddressesLoading: Boolean = false, + val emailAddressesError: String? = null, + val emailAddressesMutationLoading: Boolean = false, + val emailAddressesMutationError: String? = null, + val emailAddressesNotice: String? = null, + // E-Mail-Vorlagen + val emailTemplates: List? = null, + val emailTemplatesLoading: Boolean = false, + val emailTemplatesError: String? = null, + val emailTemplatesMutationLoading: Boolean = false, + val emailTemplatesMutationError: String? = null, + // Admin-Protokoll + val auditLog: List = emptyList(), + val auditLogPage: Int = 1, + val auditLogHasMore: Boolean = false, + val auditLogLoading: Boolean = false, + val auditLogError: String? = null, + // Aufgaben + val tasks: TaskData? = null, + val tasksFilter: String = "my", + val tasksLoading: Boolean = false, + val tasksError: String? = null, + val tasksMutationLoading: Boolean = false, + val tasksMutationError: String? = null, + // Interner Chat + val chatContacts: List? = null, + val chatContactsLoading: Boolean = false, + val chatContactsError: String? = null, + val chatConversation: ChatConversation? = null, + val chatConversationLoading: Boolean = false, + val chatConversationError: String? = null, + val chatSendLoading: Boolean = false, + val chatSendError: String? = null, + // Live-Chat (Kunden-Widget, source='chat') + val liveChats: List? = null, + val liveChatsLoading: Boolean = false, + val liveChatsError: String? = null, + val liveChatDetail: LiveChatDetail? = null, + val liveChatDetailLoading: Boolean = false, + val liveChatDetailError: String? = null, + val liveChatSendLoading: Boolean = false, + val liveChatSendError: String? = null, + // Verfügbarkeit (Status) + val availability: AvailabilityData? = null, + val availabilityLoading: Boolean = false, + val availabilityError: String? = null, + val availabilitySaving: Boolean = false, + // Ticket-Regeln (Automatisierung) + val ticketRules: TicketRulesData? = null, + val ticketRulesLoading: Boolean = false, + val ticketRulesError: String? = null, + val ticketRulesMutationLoading: Boolean = false, + val ticketRulesMutationError: String? = null, + // Bild-Upload (Signaturen) + val imageUploadLoading: Boolean = false, + val imageUploadError: String? = null, +) + +private fun currentMonthStart(): String { + val now = java.util.Calendar.getInstance() + now.set(java.util.Calendar.DAY_OF_MONTH, 1) + return java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(now.time) +} + +private fun today(): String = + java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(java.util.Date()) + +class DeskHubViewModel( + private val repository: TicketRepository, +) : ViewModel() { + private val mutableState = MutableStateFlow(DeskHubState()) + val state: StateFlow = mutableState.asStateFlow() + private var activityPollingJob: Job? = null + private var ticketsPollingJob: Job? = null + private var chatPollingJob: Job? = null + private var liveChatListPollingJob: Job? = null + private var liveChatDetailPollingJob: Job? = null + private var lastLiveChatTypingPingAt = 0L + + init { + restoreSession() + // Verbindungsstatus: bei Ausfall alle 10 s erneut versuchen und nach Rückkehr die Daten auffrischen + viewModelScope.launch { + de.deskhub.staff.data.remote.ConnectionMonitor.offline.collect { off -> + mutableState.update { it.copy(offline = off) } + if (off) { + launch { + while (isActive && de.deskhub.staff.data.remote.ConnectionMonitor.offline.value) { + delay(10_000) + if (state.value.staff != null) runCatching { repository.activitySince("") } + } + if (state.value.staff != null) { + refreshTickets() + loadDashboard() + } + } + } + } + } + } + + /** Sofort erneut versuchen (Knopf im Verbindungs-Banner). */ + fun retryConnection() { + viewModelScope.launch { runCatching { repository.activitySince("") } } + } + + fun login(serverUrl: String, email: String, password: String, totpCode: String) { + viewModelScope.launch { + mutableState.update { it.copy(authLoading = true, authError = null) } + runCatching { + repository.login(serverUrl, email, password, totpCode.ifBlank { null }) + }.onSuccess { result -> + when (result) { + LoginResult.TotpRequired -> mutableState.update { + it.copy(authLoading = false, totpRequired = true) + } + is LoginResult.Success -> { + mutableState.update { + it.copy( + initializing = false, + staff = result.staff, + authLoading = false, + authError = null, + totpRequired = false, + ) + } + loadWorkspace() + loadChatContacts() + startActivityPolling() + startTicketsPolling() + } + } + }.onFailure { error -> + mutableState.update { + it.copy(authLoading = false, authError = error.userMessage()) + } + } + } + } + + fun cancelTotp() { + mutableState.update { it.copy(totpRequired = false, authError = null) } + } + + fun logout() { + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.logout() } + .onSuccess { + activityPollingJob?.cancel() + ticketsPollingJob?.cancel() + chatPollingJob?.cancel() + mutableState.value = DeskHubState(initializing = false, savedServerUrl = repository.lastServerUrl()) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun clearActivityBadge() { + mutableState.update { it.copy(activityBadgeCount = 0) } + } + + private fun startActivityPolling() { + activityPollingJob?.cancel() + activityPollingJob = viewModelScope.launch { + var since = "" + while (isActive) { + runCatching { repository.activitySince(since) }.onSuccess { update: ActivityUpdate -> + since = update.serverTime + mutableState.update { it.copy(healthIssues = update.health) } + val newCount = update.newReplies + update.newUnassigned + if (newCount > 0) { + mutableState.update { it.copy(activityBadgeCount = it.activityBadgeCount + newCount) } + } + } + delay(60_000) + } + } + } + + // Läuft dauerhaft im Hintergrund (auch außerhalb des Tickets-Tabs, analog zu + // startActivityPolling()), damit neue/geänderte Tickets von selbst auftauchen, ohne + // dass manuell auf "Aktualisieren" getippt werden muss – Pendant zum Web-Live-Refresh + // in staff/tickets.php. Aktualisiert bewusst nur leise Seite 1 (kein ticketsLoading = + // kein Flackern) und nur, solange der Nutzer nicht gerade auf einer Folgeseite ist + // ("Weitere laden") – sonst würde die Liste unter den Fingern auf Seite 1 zurückspringen. + private fun startTicketsPolling() { + ticketsPollingJob?.cancel() + ticketsPollingJob = viewModelScope.launch { + while (isActive) { + delay(15_000) + if (state.value.staff != null && state.value.ticketsPage <= 1 && !state.value.ticketsLoading) { + runCatching { repository.tickets(state.value.filters, 1) } + .onSuccess { page -> + mutableState.update { + it.copy( + tickets = page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ) + } + } + // Fehler werden bewusst verschluckt: ein einzelner Netzwerk-Hänger im + // Hintergrund soll weder die sichtbare Liste löschen noch eine + // Fehlermeldung aufploppen lassen – beim nächsten Tick wird es erneut versucht. + } + } + } + } + + fun updateFilters(filters: TicketFilters) { + mutableState.update { it.copy(filters = filters) } + } + + fun refreshTickets() { + loadTickets(reset = true) + } + + fun loadMoreTickets() { + if (!state.value.ticketsHasMore || state.value.ticketsLoading) return + loadTickets(reset = false) + } + + fun openTicket(number: String) { + viewModelScope.launch { + mutableState.update { + it.copy(selectedTicket = null, detailLoading = true, detailError = null) + } + runCatching { repository.ticket(number) } + .onSuccess { ticket -> + mutableState.update { + it.copy(selectedTicket = ticket, detailLoading = false) + } + } + .onFailure { error -> + mutableState.update { + it.copy(detailLoading = false, detailError = error.userMessage()) + } + } + } + } + + fun updateTicket(status: String, assignedStaffId: Int?) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + val updateAssignment = assignedStaffId != ticket.summary.assignedStaff?.id + runCatching { + repository.updateTicket( + number = ticket.summary.number, + status = status.takeIf { it != ticket.summary.status }, + assignedStaffId = assignedStaffId, + updateAssignment = updateAssignment, + ) + }.onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + openTicket(ticket.summary.number) + loadTickets(reset = true) + }.onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + /** Markiert das aktuell geöffnete Ticket als Spam, oder macht das (restore = true) rückgängig. */ + fun setTicketSpam(restore: Boolean) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.setTicketSpam(ticket.summary.number, restore) } + .onSuccess { result -> + mutableState.update { it.copy(mutationLoading = false) } + if (result.failed.isNotEmpty()) { + val verb = if (restore) "zurückgeholt" else "als Spam markiert" + mutableState.update { + it.copy(mutationError = "Ticket wurde $verb, aber nicht alle Original-Mails konnten verschoben werden: " + result.failed.joinToString(" | ")) + } + } + openTicket(ticket.summary.number) + loadTickets(reset = true) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun addMessage(type: String, message: String, setStatus: String?, onSuccess: () -> Unit) { + val number = state.value.selectedTicket?.summary?.number ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.addMessage(number, type, message, setStatus) } + .onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + onSuccess() + openTicket(number) + loadTickets(reset = true) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun clearMutationError() { + mutableState.update { it.copy(mutationError = null) } + } + + fun searchCustomers(query: String) { + mutableState.update { it.copy(customersQuery = query) } + viewModelScope.launch { + mutableState.update { it.copy(customersLoading = true, customersError = null) } + runCatching { repository.customers(query) } + .onSuccess { list -> mutableState.update { it.copy(customers = list, customersLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(customersLoading = false, customersError = error.userMessage()) } + } + } + } + + fun openCustomer(customer: Customer) { + viewModelScope.launch { + mutableState.update { it.copy(selectedCustomer = null, customerLoading = true, customerError = null) } + runCatching { + if (customer.id != null) repository.customerProfile(customer.id, null) + else repository.customerProfile(null, customer.email) + } + .onSuccess { profile -> mutableState.update { it.copy(selectedCustomer = profile, customerLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(customerLoading = false, customerError = error.userMessage()) } + } + } + } + + fun startNewTicket(name: String = "", email: String = "") { + mutableState.update { + it.copy(newTicketPrefillName = name, newTicketPrefillEmail = email, newTicketError = null) + } + } + + fun createTicket( + requesterName: String, + requesterEmail: String, + subject: String, + message: String, + departmentId: Int, + priorityId: Int, + assignedStaffId: Int?, + notifyCustomer: Boolean, + onSuccess: (String) -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(newTicketLoading = true, newTicketError = null) } + runCatching { + repository.createTicket( + requesterName, requesterEmail, subject, message, + departmentId, priorityId, assignedStaffId, notifyCustomer, + ) + } + .onSuccess { ticket -> + mutableState.update { it.copy(newTicketLoading = false) } + onSuccess(ticket.number) + refreshTickets() + } + .onFailure { error -> + mutableState.update { it.copy(newTicketLoading = false, newTicketError = error.userMessage()) } + } + } + } + + fun loadDashboard() { + viewModelScope.launch { + mutableState.update { it.copy(dashboardLoading = true, dashboardError = null) } + runCatching { repository.dashboard() } + .onSuccess { stats -> + mutableState.update { it.copy(dashboard = stats, dashboardLoading = false) } + } + .onFailure { error -> + mutableState.update { + it.copy(dashboardLoading = false, dashboardError = error.userMessage()) + } + } + } + } + + // ─── Admin: Wissensdatenbank ────────────────────────────────────────────── + + fun loadKb() { + viewModelScope.launch { + mutableState.update { it.copy(kbLoading = true, kbError = null) } + runCatching { repository.kbData() } + .onSuccess { data -> mutableState.update { it.copy(kb = data, kbLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(kbLoading = false, kbError = error.userMessage()) } } + } + } + + fun saveKbArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbSaveArticle(id, title, categoryId, body, isPublished) } + .onSuccess { + mutableState.update { it.copy(kbMutationLoading = false) } + onSuccess() + loadKb() + } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun deleteKbArticle(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbDeleteArticle(id) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun createKbCategory(name: String) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbCreateCategory(name) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun deleteKbCategory(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbDeleteCategory(id) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun clearKbMutationError() { + mutableState.update { it.copy(kbMutationError = null) } + } + + // ─── Admin: Zeiterfassung ───────────────────────────────────────────────── + + fun loadTimeEntries(staffId: Int? = state.value.timeEntriesStaffId, from: String = state.value.timeEntriesFrom, to: String = state.value.timeEntriesTo) { + viewModelScope.launch { + mutableState.update { + it.copy( + timeEntriesLoading = true, + timeEntriesError = null, + timeEntriesStaffId = staffId, + timeEntriesFrom = from, + timeEntriesTo = to, + ) + } + runCatching { repository.timeEntries(staffId, from, to) } + .onSuccess { report -> mutableState.update { it.copy(timeEntries = report, timeEntriesLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(timeEntriesLoading = false, timeEntriesError = error.userMessage()) } + } + } + } + + // ─── Admin: Eskalationsregeln ───────────────────────────────────────────── + + fun loadEscalationRules() { + viewModelScope.launch { + mutableState.update { it.copy(escalationLoading = true, escalationError = null) } + runCatching { repository.escalationData() } + .onSuccess { data -> mutableState.update { it.copy(escalation = data, escalationLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(escalationLoading = false, escalationError = error.userMessage()) } } + } + } + + fun saveEscalationRule( + id: Int?, + name: String, + isActive: Boolean, + triggerType: String, + triggerHours: Int, + conditionStatus: String?, + conditionPriorityId: Int?, + conditionDeptId: Int?, + actionSetPriorityId: Int?, + actionAssignStaffId: Int?, + actionSetStatus: String?, + actionNotifyAdmin: Boolean, + actionNotifyStaff: Boolean, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } + runCatching { + repository.escalationSave( + id = id, name = name, isActive = isActive, triggerType = triggerType, + triggerHours = triggerHours, conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, conditionDeptId = conditionDeptId, + actionSetPriorityId = actionSetPriorityId, actionAssignStaffId = actionAssignStaffId, + actionSetStatus = actionSetStatus, actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, sortOrder = 0, + ) + }.onSuccess { + mutableState.update { it.copy(escalationMutationLoading = false) } + onSuccess() + loadEscalationRules() + }.onFailure { error -> + mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } + } + } + } + + fun deleteEscalationRule(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } + runCatching { repository.escalationDelete(id) } + .onSuccess { mutableState.update { it.copy(escalationMutationLoading = false) }; loadEscalationRules() } + .onFailure { error -> + mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } + } + } + } + + fun toggleEscalationRule(id: Int) { + viewModelScope.launch { + runCatching { repository.escalationToggle(id) } + .onSuccess { loadEscalationRules() } + .onFailure { error -> mutableState.update { it.copy(escalationMutationError = error.userMessage()) } } + } + } + + fun clearEscalationMutationError() { + mutableState.update { it.copy(escalationMutationError = null) } + } + + // ─── Admin: Sperrliste ──────────────────────────────────────────────────── + + fun loadBanlist() { + viewModelScope.launch { + mutableState.update { it.copy(banlistLoading = true, banlistError = null) } + runCatching { repository.banlist() } + .onSuccess { items -> mutableState.update { it.copy(banlist = items, banlistLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(banlistLoading = false, banlistError = error.userMessage()) } } + } + } + + fun createBanEntry(matchType: String, value: String, reason: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banCreate(matchType, value, reason) } + .onSuccess { + mutableState.update { it.copy(banlistMutationLoading = false) } + onSuccess() + loadBanlist() + } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun bulkAddBanEntries(values: String, reason: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banBulkAdd(values, reason) } + .onSuccess { result -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistLastBulkResult = result) } + onSuccess() + loadBanlist() + } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun deleteBanEntry(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banDelete(id) } + .onSuccess { mutableState.update { it.copy(banlistMutationLoading = false) }; loadBanlist() } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun clearBanlistMutationError() { + mutableState.update { it.copy(banlistMutationError = null) } + } + + fun clearBanlistBulkResult() { + mutableState.update { it.copy(banlistLastBulkResult = null) } + } + + // ─── Admin: Abteilungen ─────────────────────────────────────────────────── + + fun loadDepartments() { + viewModelScope.launch { + mutableState.update { it.copy(departments2Loading = true, departments2Error = null) } + runCatching { repository.departmentData() } + .onSuccess { d -> mutableState.update { it.copy(departments2 = d.departments, departmentEmailOptions = d.emailOptions, departmentStaffOptions = d.staffOptions, departmentSlaPlans = d.slaPlans, departments2Loading = false) } } + .onFailure { error -> mutableState.update { it.copy(departments2Loading = false, departments2Error = error.userMessage()) } } + } + } + + fun saveDepartment(id: Int?, input: DepartmentInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } + runCatching { repository.departmentSave(id, input) } + .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; onSuccess(); loadDepartments() } + .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } + } + } + + fun deleteDepartment(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } + runCatching { repository.departmentDelete(id) } + .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; loadDepartments() } + .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } + } + } + + fun clearDepartmentsError() = mutableState.update { it.copy(departments2MutationError = null) } + + // ─── Admin: Prioritäten ─────────────────────────────────────────────────── + + fun loadPrioritiesAdmin() { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminLoading = true, prioritiesAdminError = null) } + runCatching { repository.prioritiesAdmin() } + .onSuccess { items -> mutableState.update { it.copy(prioritiesAdmin = items, prioritiesAdminLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminLoading = false, prioritiesAdminError = error.userMessage()) } } + } + } + + fun savePriority(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } + runCatching { repository.prioritySave(id, name, color, responseHours, resolutionHours, sortOrder) } + .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; onSuccess(); loadPrioritiesAdmin() } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } + } + } + + fun deletePriority(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } + runCatching { repository.priorityDelete(id) } + .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; loadPrioritiesAdmin() } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } + } + } + + /** Bericht ausführen: mit der übergebenen Konfiguration oder als gespeicherter Bericht (savedId). */ + fun runReport(config: de.deskhub.staff.data.remote.dto.ReportConfigDto? = null, savedId: Int? = null) { + viewModelScope.launch { + mutableState.update { it.copy(reportLoading = true, reportError = null) } + runCatching { repository.report(config, savedId) } + .onSuccess { r -> mutableState.update { it.copy(report = r, reportConfig = r.config, reportLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(reportLoading = false, reportError = error.userMessage()) } } + } + } + + fun saveReport(name: String, shared: Boolean) { + viewModelScope.launch { + runCatching { repository.reportSave(name, shared, state.value.reportConfig) } + .onSuccess { runReport(state.value.reportConfig) } + .onFailure { error -> mutableState.update { it.copy(reportError = error.userMessage()) } } + } + } + + fun deleteReport(id: Int) { + viewModelScope.launch { + runCatching { repository.reportDelete(id) }.onSuccess { runReport(state.value.reportConfig) } + } + } + + fun loadSavedViews() { + viewModelScope.launch { + runCatching { repository.savedViews() } + .onSuccess { views -> mutableState.update { it.copy(savedViews = views) } } + } + } + + fun applySavedView(view: de.deskhub.staff.domain.model.SavedView) { + mutableState.update { it.copy(filters = view.filters) } + refreshTickets() + } + + fun saveCurrentView(name: String, shared: Boolean) { + viewModelScope.launch { + mutableState.update { it.copy(savedViewError = null) } + runCatching { repository.savedViewSave(name, shared, state.value.filters) } + .onSuccess { loadSavedViews() } + .onFailure { error -> mutableState.update { it.copy(savedViewError = error.userMessage()) } } + } + } + + fun deleteSavedView(id: Int) { + viewModelScope.launch { + runCatching { repository.savedViewDelete(id) }.onSuccess { loadSavedViews() } + } + } + + fun clearSavedViewError() = mutableState.update { it.copy(savedViewError = null) } + + fun loadSlaPlans() { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansLoading = true, slaPlansError = null) } + runCatching { repository.slaPlans() } + .onSuccess { items -> mutableState.update { it.copy(slaPlans = items, slaPlansLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(slaPlansLoading = false, slaPlansError = error.userMessage()) } } + } + } + + fun saveSlaPlan(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } + runCatching { repository.slaPlanSave(id, name, responseHours, resolutionHours, isActive, notes) } + .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; onSuccess(); loadSlaPlans() } + .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } + } + } + + fun deleteSlaPlan(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } + runCatching { repository.slaPlanDelete(id) } + .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; loadSlaPlans() } + .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } + } + } + + fun clearSlaPlansError() = mutableState.update { it.copy(slaPlansMutationError = null) } + + fun clearPrioritiesError() = mutableState.update { it.copy(prioritiesAdminMutationError = null) } + + // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── + + fun loadHelpTopics() { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsLoading = true, helpTopicsError = null) } + runCatching { repository.helpTopics() } + .onSuccess { data -> mutableState.update { it.copy(helpTopics = data, helpTopicsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(helpTopicsLoading = false, helpTopicsError = error.userMessage()) } } + } + } + + fun saveHelpTopic(id: Int?, input: HelpTopicInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } + runCatching { repository.helpTopicSave(id, input) } + .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; onSuccess(); loadHelpTopics() } + .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } + } + } + + fun deleteHelpTopic(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } + runCatching { repository.helpTopicDelete(id) } + .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; loadHelpTopics() } + .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } + } + } + + fun clearHelpTopicsError() = mutableState.update { it.copy(helpTopicsMutationError = null) } + + // ─── Admin: Organisationen ──────────────────────────────────────────────── + + fun loadOrganizations() { + viewModelScope.launch { + mutableState.update { it.copy(organizationsLoading = true, organizationsError = null) } + runCatching { repository.organizations() } + .onSuccess { items -> mutableState.update { it.copy(organizations = items, organizationsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(organizationsLoading = false, organizationsError = error.userMessage()) } } + } + } + + fun saveOrganization(id: Int?, name: String, notes: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } + runCatching { repository.organizationSave(id, name, notes) } + .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; onSuccess(); loadOrganizations() } + .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } + } + } + + fun deleteOrganization(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } + runCatching { repository.organizationDelete(id) } + .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; loadOrganizations() } + .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } + } + } + + fun clearOrganizationsError() = mutableState.update { it.copy(organizationsMutationError = null) } + + // ─── Admin: Teams ───────────────────────────────────────────────────────── + + fun loadTeams() { + viewModelScope.launch { + mutableState.update { it.copy(teamsLoading = true, teamsError = null) } + runCatching { repository.teams() } + .onSuccess { data -> mutableState.update { it.copy(teams = data, teamsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(teamsLoading = false, teamsError = error.userMessage()) } } + } + } + + fun saveTeam(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } + runCatching { repository.teamSave(id, name, leadId, notes, memberIds) } + .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; onSuccess(); loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } + } + } + + fun toggleTeam(id: Int) { + viewModelScope.launch { + runCatching { repository.teamToggle(id) } + .onSuccess { loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationError = error.userMessage()) } } + } + } + + fun deleteTeam(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } + runCatching { repository.teamDelete(id) } + .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } + } + } + + fun clearTeamsError() = mutableState.update { it.copy(teamsMutationError = null) } + + // ─── Admin: Rollen ──────────────────────────────────────────────────────── + + fun loadRoles() { + viewModelScope.launch { + mutableState.update { it.copy(rolesLoading = true, rolesError = null) } + runCatching { repository.roles() } + .onSuccess { data -> mutableState.update { it.copy(roles = data, rolesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(rolesLoading = false, rolesError = error.userMessage()) } } + } + } + + fun saveRole(id: Int?, name: String, permissions: Map, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } + runCatching { repository.roleSave(id, name, permissions) } + .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; onSuccess(); loadRoles() } + .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } + } + } + + fun deleteRole(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } + runCatching { repository.roleDelete(id) } + .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; loadRoles() } + .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } + } + } + + fun clearRolesError() = mutableState.update { it.copy(rolesMutationError = null) } + + // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── + + fun loadStaffAdmin() { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminLoading = true, staffAdminError = null) } + runCatching { repository.staffAdminList() } + .onSuccess { data -> mutableState.update { it.copy(staffAdmin = data, staffAdminLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(staffAdminLoading = false, staffAdminError = error.userMessage()) } } + } + } + + fun saveStaffAdmin(id: Int?, input: StaffAdminInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } + runCatching { repository.staffAdminSave(id, input) } + .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; onSuccess(); loadStaffAdmin() } + .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } + } + } + + fun deleteStaffAdmin(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } + runCatching { repository.staffAdminDelete(id) } + .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; loadStaffAdmin() } + .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } + } + } + + fun clearStaffAdminError() = mutableState.update { it.copy(staffAdminMutationError = null) } + + // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── + + fun loadBusinessHours() { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursLoading = true, businessHoursError = null) } + runCatching { repository.businessHours() } + .onSuccess { data -> mutableState.update { it.copy(businessHours = data, businessHoursLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(businessHoursLoading = false, businessHoursError = error.userMessage()) } } + } + } + + fun saveBusinessHours(enabled: Boolean, hours: List, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } + runCatching { repository.businessHoursSave(enabled, hours) } + .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; onSuccess(); loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } + } + } + + fun addBusinessHoliday(date: String, name: String) { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } + runCatching { repository.businessHolidayAdd(date, name) } + .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } + } + } + + fun deleteBusinessHoliday(id: Int) { + viewModelScope.launch { + runCatching { repository.businessHolidayDelete(id) } + .onSuccess { loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationError = error.userMessage()) } } + } + } + + fun clearBusinessHoursError() = mutableState.update { it.copy(businessHoursMutationError = null) } + + // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── + + fun loadEmailAddresses() { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesLoading = true, emailAddressesError = null) } + runCatching { repository.emailAddresses() } + .onSuccess { data -> mutableState.update { it.copy(emailAddresses = data, emailAddressesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(emailAddressesLoading = false, emailAddressesError = error.userMessage()) } } + } + } + + fun saveEmailAddress(id: Int?, input: EmailAddressInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } + runCatching { repository.emailAddressSave(id, input) } + .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; onSuccess(); loadEmailAddresses() } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + fun deleteEmailAddress(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null) } + runCatching { repository.emailAddressDelete(id) } + .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; loadEmailAddresses() } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + /** Test-Mail senden ("sendTest") bzw. IMAP-Verbindung prüfen ("testImap"); Ergebnis erscheint als Hinweis im Formular. */ + fun testEmailAddress(id: Int, action: String) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } + runCatching { repository.emailAddressAction(action, id) } + .onSuccess { msg -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesNotice = msg.ifBlank { "OK" }) } } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + fun clearEmailAddressesError() = mutableState.update { it.copy(emailAddressesMutationError = null, emailAddressesNotice = null) } + + // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── + + fun loadEmailTemplates() { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesLoading = true, emailTemplatesError = null) } + runCatching { repository.emailTemplates() } + .onSuccess { items -> mutableState.update { it.copy(emailTemplates = items, emailTemplatesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesLoading = false, emailTemplatesError = error.userMessage()) } } + } + } + + fun saveEmailTemplate(key: String, subject: String, bodyText: String, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } + runCatching { repository.emailTemplateSave(key, subject, bodyText) } + .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; onSuccess(); loadEmailTemplates() } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } + } + } + + fun resetEmailTemplate(key: String) { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } + runCatching { repository.emailTemplateReset(key) } + .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; loadEmailTemplates() } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } + } + } + + fun clearEmailTemplatesError() = mutableState.update { it.copy(emailTemplatesMutationError = null) } + + // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── + + fun loadAuditLog(reset: Boolean = true) { + viewModelScope.launch { + val page = if (reset) 1 else state.value.auditLogPage + 1 + mutableState.update { it.copy(auditLogLoading = true, auditLogError = null) } + runCatching { repository.auditLog(page) } + .onSuccess { result -> + mutableState.update { + it.copy( + auditLog = if (reset) result.items else it.auditLog + result.items, + auditLogPage = result.page, + auditLogHasMore = result.hasMore, + auditLogLoading = false, + ) + } + } + .onFailure { error -> mutableState.update { it.copy(auditLogLoading = false, auditLogError = error.userMessage()) } } + } + } + + // ─── Aufgaben ───────────────────────────────────────────────────────────── + + fun loadTasks(filter: String = state.value.tasksFilter) { + viewModelScope.launch { + mutableState.update { it.copy(tasksLoading = true, tasksError = null, tasksFilter = filter) } + runCatching { repository.tasks(filter) } + .onSuccess { data -> mutableState.update { it.copy(tasks = data, tasksLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(tasksLoading = false, tasksError = error.userMessage()) } } + } + } + + fun createTask(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(tasksMutationLoading = true, tasksMutationError = null) } + runCatching { repository.taskCreate(title, description, assignedToId, dueAt, ticketNumber) } + .onSuccess { mutableState.update { it.copy(tasksMutationLoading = false) }; onSuccess(); loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationLoading = false, tasksMutationError = error.userMessage()) } } + } + } + + fun updateTaskStatus(id: Int, status: String) { + viewModelScope.launch { + runCatching { repository.taskUpdateStatus(id, status) } + .onSuccess { loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } + } + } + + fun deleteTask(id: Int) { + viewModelScope.launch { + runCatching { repository.taskDelete(id) } + .onSuccess { loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } + } + } + + fun clearTasksError() = mutableState.update { it.copy(tasksMutationError = null) } + + // ─── Interner Chat ──────────────────────────────────────────────────────── + + fun loadChatContacts() { + viewModelScope.launch { + mutableState.update { it.copy(chatContactsLoading = true, chatContactsError = null) } + runCatching { repository.chatContacts() } + .onSuccess { items -> mutableState.update { it.copy(chatContacts = items, chatContactsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(chatContactsLoading = false, chatContactsError = error.userMessage()) } } + } + } + + fun openChatConversation(withId: Int) { + chatPollingJob?.cancel() + mutableState.update { it.copy(chatConversation = null, chatConversationLoading = true, chatConversationError = null) } + chatPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.chatConversation(withId) } + .onSuccess { conversation -> + mutableState.update { it.copy(chatConversation = conversation, chatConversationLoading = false) } + } + .onFailure { error -> + mutableState.update { it.copy(chatConversationLoading = false, chatConversationError = error.userMessage()) } + } + delay(8_000) + } + } + } + + fun closeChatConversation() { + chatPollingJob?.cancel() + mutableState.update { it.copy(chatConversation = null) } + loadChatContacts() + } + + fun sendChatMessage(toId: Int, message: String) { + viewModelScope.launch { + mutableState.update { it.copy(chatSendLoading = true, chatSendError = null) } + runCatching { repository.chatSend(toId, message) } + .onSuccess { + mutableState.update { it.copy(chatSendLoading = false) } + runCatching { repository.chatConversation(toId) } + .onSuccess { conversation -> mutableState.update { it.copy(chatConversation = conversation) } } + } + .onFailure { error -> mutableState.update { it.copy(chatSendLoading = false, chatSendError = error.userMessage()) } } + } + } + + fun clearChatSendError() = mutableState.update { it.copy(chatSendError = null) } + + // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── + // Eigener Tab, getrennt vom internen Chat oben – Chats mit Kunden über das + // Live-Chat-Widget im Kundenportal (siehe staff/live_chat.php im Web-Backend, + // gleiche Sperr-Logik über api/staff/v1/live_chat.php). + + /** Fügt neu eingetroffene Nachrichten hinzu, ohne bereits angezeigte zu duplizieren + * (der Server liefert bei jedem Poll immer die volle Historie, wie im Web-Widget). */ + private fun mergeLiveChatDetail(state: DeskHubState, detail: LiveChatDetail): LiveChatDetail { + val prevMessages = state.liveChatDetail?.messages.orEmpty() + val existingIds = prevMessages.map { it.id }.toSet() + val newOnes = detail.messages.filter { it.id !in existingIds } + return detail.copy(messages = prevMessages + newOnes) + } + + fun loadLiveChats() { + liveChatListPollingJob?.cancel() + mutableState.update { it.copy(liveChatsLoading = true, liveChatsError = null) } + liveChatListPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.liveChatList() } + .onSuccess { items -> mutableState.update { it.copy(liveChats = items, liveChatsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(liveChatsLoading = false, liveChatsError = error.userMessage()) } } + delay(8_000) + } + } + } + + fun stopLiveChatListPolling() { + liveChatListPollingJob?.cancel() + } + + fun openLiveChat(number: String) { + liveChatDetailPollingJob?.cancel() + mutableState.update { it.copy(liveChatDetail = null, liveChatDetailLoading = true, liveChatDetailError = null) } + liveChatDetailPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.liveChatMessages(number) } + .onSuccess { detail -> + mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail), liveChatDetailLoading = false) } + } + .onFailure { error -> mutableState.update { it.copy(liveChatDetailLoading = false, liveChatDetailError = error.userMessage()) } } + delay(8_000) + } + } + } + + fun closeLiveChat() { + liveChatDetailPollingJob?.cancel() + mutableState.update { it.copy(liveChatDetail = null) } + } + + fun sendLiveChatMessage(number: String, message: String) { + viewModelScope.launch { + mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } + runCatching { repository.liveChatSend(number, message) } + .onSuccess { + mutableState.update { it.copy(liveChatSendLoading = false) } + // Sofort einmal nachladen statt bis zu 8s auf den nächsten Poll zu warten. + runCatching { repository.liveChatMessages(number) } + .onSuccess { detail -> mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail)) } } + } + .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } + } + } + + fun clearLiveChatSendError() = mutableState.update { it.copy(liveChatSendError = null) } + + /** Signalisiert dem Kunden-Widget "Mitarbeiter tippt gerade" – höchstens alle 3s, + * damit nicht bei jedem Tastenanschlag eine eigene Anfrage rausgeht. */ + fun pingLiveChatTyping(number: String) { + val now = System.currentTimeMillis() + if (now - lastLiveChatTypingPingAt < 3_000) return + lastLiveChatTypingPingAt = now + viewModelScope.launch { runCatching { repository.liveChatTyping(number) } } + } + + fun endLiveChat(number: String, onDone: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } + runCatching { repository.liveChatEnd(number) } + .onSuccess { + mutableState.update { it.copy(liveChatSendLoading = false) } + onDone() + } + .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } + } + } + + // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── + + fun loadAvailability() { + viewModelScope.launch { + mutableState.update { it.copy(availabilityLoading = true, availabilityError = null) } + runCatching { repository.availability() } + .onSuccess { data -> mutableState.update { it.copy(availability = data, availabilityLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(availabilityLoading = false, availabilityError = error.userMessage()) } } + } + } + + fun setMyAvailability(status: String, note: String) { + viewModelScope.launch { + mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) } + runCatching { repository.setAvailability(status, note) } + .onSuccess { + mutableState.update { it.copy(availabilitySaving = false) } + loadAvailability() + } + .onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } } + } + } + + // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── + + fun loadTicketRules() { + viewModelScope.launch { + mutableState.update { it.copy(ticketRulesLoading = true, ticketRulesError = null) } + runCatching { repository.ticketRules() } + .onSuccess { data -> mutableState.update { it.copy(ticketRules = data, ticketRulesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(ticketRulesLoading = false, ticketRulesError = error.userMessage()) } } + } + } + + fun saveTicketRule( + id: Int?, + name: String, + matchType: String, + isActive: Boolean, + sortOrder: Int, + conditions: List, + actions: List, + onDone: () -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(ticketRulesMutationLoading = true, ticketRulesMutationError = null) } + runCatching { repository.saveTicketRule(id, name, matchType, isActive, sortOrder, conditions, actions) } + .onSuccess { + mutableState.update { it.copy(ticketRulesMutationLoading = false) } + loadTicketRules() + onDone() + } + .onFailure { error -> mutableState.update { it.copy(ticketRulesMutationLoading = false, ticketRulesMutationError = error.userMessage()) } } + } + } + + fun toggleTicketRule(id: Int) { + viewModelScope.launch { + runCatching { repository.toggleTicketRule(id) }.onSuccess { loadTicketRules() } + } + } + + fun deleteTicketRule(id: Int) { + viewModelScope.launch { + runCatching { repository.deleteTicketRule(id) }.onSuccess { loadTicketRules() } + } + } + + fun clearTicketRulesMutationError() = mutableState.update { it.copy(ticketRulesMutationError = null) } + + fun saveAutoClose(enabled: Boolean, days: Int) { + viewModelScope.launch { + runCatching { repository.saveAutoClose(enabled, days) }.onSuccess { loadTicketRules() } + } + } + + // ─── Bild-Upload (Signaturen) ───────────────────────────────────────────── + + fun uploadSignatureImage(bytes: ByteArray, filename: String, mimeType: String, onSuccess: (String) -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(imageUploadLoading = true, imageUploadError = null) } + runCatching { repository.uploadImage(bytes, filename, mimeType) } + .onSuccess { url -> + mutableState.update { it.copy(imageUploadLoading = false) } + onSuccess(url) + } + .onFailure { error -> mutableState.update { it.copy(imageUploadLoading = false, imageUploadError = error.userMessage()) } } + } + } + + fun clearImageUploadError() = mutableState.update { it.copy(imageUploadError = null) } + + private fun restoreSession() { + viewModelScope.launch { + val restored = runCatching { repository.restoreSession() } + if (restored.isFailure || restored.getOrNull() == null) { + mutableState.update { + it.copy( + initializing = false, + authError = restored.exceptionOrNull()?.userMessage(), + savedServerUrl = repository.lastServerUrl(), + ) + } + return@launch + } + runCatching { repository.validateSession() } + .onSuccess { staff -> + mutableState.update { it.copy(initializing = false, staff = staff) } + loadWorkspace() + loadChatContacts() + startActivityPolling() + startTicketsPolling() + } + .onFailure { error -> + if (error is ApiException && error.status == 401) repository.clearSession() + mutableState.update { + it.copy(initializing = false, authError = error.userMessage(), savedServerUrl = repository.lastServerUrl()) + } + } + } + } + + private fun loadWorkspace() { + viewModelScope.launch { + mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } + val metadataRequest = async { repository.metadata() } + val ticketsRequest = async { repository.tickets(state.value.filters) } + val cannedRequest = async { runCatching { repository.cannedResponses() }.getOrDefault(emptyList()) } + runCatching { Triple(metadataRequest.await(), ticketsRequest.await(), cannedRequest.await()) } + .onSuccess { (metadata, page, canned) -> + mutableState.update { + it.copy( + metadata = metadata, + staff = metadata.staff, + cannedResponses = canned, + tickets = page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ticketsLoading = false, + ) + } + } + .onFailure { error -> + mutableState.update { + it.copy(ticketsLoading = false, ticketsError = error.userMessage()) + } + } + } + } + + private fun loadTickets(reset: Boolean) { + viewModelScope.launch { + val pageNumber = if (reset) 1 else state.value.ticketsPage + 1 + mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } + runCatching { repository.tickets(state.value.filters, pageNumber) } + .onSuccess { page -> + mutableState.update { + it.copy( + tickets = if (reset) page.items else it.tickets + page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ticketsLoading = false, + ) + } + } + .onFailure { error -> + mutableState.update { + it.copy(ticketsLoading = false, ticketsError = error.userMessage()) + } + } + } + } +} + +class DeskHubViewModelFactory( + private val repository: TicketRepository, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(DeskHubViewModel::class.java)) + return DeskHubViewModel(repository) as T + } +} + +private fun Throwable.userMessage(): String = when (this) { + is ApiException -> message + is IllegalArgumentException -> message ?: "Ungültige Eingabe." + else -> message ?: "Die Aktion ist fehlgeschlagen." +} diff --git a/mobile/src/main/java/de/deskhub/staff/ui/Permissions.kt b/mobile/src/main/java/de/deskhub/staff/ui/Permissions.kt new file mode 100644 index 00000000..a61bb45b --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/Permissions.kt @@ -0,0 +1,12 @@ +package de.deskhub.staff.ui + +/** + * Darf der Mitarbeiter diese Aktion an einem Ticket ausführen? + * Rechte gelten pro Abteilung des Tickets; liefert ein älterer Server sie nicht mit, + * gelten die globalen Rechte des Mitarbeiters. + */ +fun ticketPermission( + ticketPermissions: Map?, + staffPermissions: Map?, + permission: String, +): Boolean = ticketPermissions?.get(permission) ?: (staffPermissions?.get(permission) == true) diff --git a/mobile/src/main/res/drawable/ic_launcher_background.xml b/mobile/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..82d55963 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_launcher_foreground.xml b/mobile/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..bc816d61 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + diff --git a/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/values-night/themes.xml b/mobile/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..751df6cc --- /dev/null +++ b/mobile/src/main/res/values-night/themes.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml new file mode 100644 index 00000000..acb900ce --- /dev/null +++ b/mobile/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + deskhub + deskhub-Server + diff --git a/mobile/src/main/res/values/themes.xml b/mobile/src/main/res/values/themes.xml new file mode 100644 index 00000000..ae018113 --- /dev/null +++ b/mobile/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/src/main/res/xml/network_security_config.xml b/mobile/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..b0e4af7a --- /dev/null +++ b/mobile/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/ConnectionMonitorTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/ConnectionMonitorTest.kt new file mode 100644 index 00000000..a5c52f18 --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/ConnectionMonitorTest.kt @@ -0,0 +1,21 @@ +package de.deskhub.staff.data.remote + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionMonitorTest { + @After + fun reset() = ConnectionMonitor.set(false) + + @Test + fun `starts online and follows set`() { + ConnectionMonitor.set(false) + assertFalse(ConnectionMonitor.offline.value) + ConnectionMonitor.set(true) + assertTrue(ConnectionMonitor.offline.value) + ConnectionMonitor.set(false) + assertFalse(ConnectionMonitor.offline.value) + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/DeskHubUrlTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/DeskHubUrlTest.kt new file mode 100644 index 00000000..7cc78ef9 --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/DeskHubUrlTest.kt @@ -0,0 +1,29 @@ +package de.deskhub.staff.data.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class DeskHubUrlTest { + @Test + fun `adds https and preserves installation path`() { + assertEquals( + "https://support.example.de/deskhub", + DeskHubUrl.normalize("support.example.de/deskhub/"), + ) + } + + @Test + fun `rejects cleartext server`() { + assertThrows(IllegalArgumentException::class.java) { + DeskHubUrl.normalize("http://support.example.de") + } + } + + @Test + fun `rejects credentials and query parameters`() { + assertThrows(IllegalArgumentException::class.java) { + DeskHubUrl.normalize("https://user@example.de?token=secret") + } + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt new file mode 100644 index 00000000..4c8bfbaa --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt @@ -0,0 +1,84 @@ +package de.deskhub.staff.data.remote.dto + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Prüft, dass typische Server-Antworten (Format der PHP-API) korrekt gelesen werden. */ +class ApiDtoParsingTest { + private val gson = Gson() + + @Test + fun `metadata contains health issues and permissions`() { + val json = """{"staff":{"id":3,"name":"Max","email":"m@x.de","role":"agent","permissions":{"view_reports":true,"reply_ticket":false}}, + "departments":[{"id":1,"name":"Technik"}],"priorities":[],"agents":[],"statuses":[],"tags":[],"liveChatEnabled":true, + "health":[{"level":"error","code":"mailbox_down","message":"Postfach nicht erreichbar"}]}""" + val m = gson.fromJson(json, MetadataResponse::class.java) + assertEquals("Max", m.staff.name) + assertTrue(m.staff.permissions["view_reports"] == true) + assertFalse(m.staff.permissions["reply_ticket"] == true) + assertEquals(1, m.health.size) + assertEquals("mailbox_down", m.health[0].code) + assertEquals("error", m.health[0].level) + } + + @Test + fun `old server without health or permissions still parses`() { + val m = gson.fromJson("""{"staff":{"id":1,"name":"A","email":"a@x.de","role":"admin"}}""", MetadataResponse::class.java) + assertTrue(m.health.isEmpty()) + assertTrue(m.staff.permissions.isEmpty()) + val t = gson.fromJson("""{"id":5,"number":"T-1","subject":"S"}""", TicketDetailDto::class.java) + assertNull(t.permissions) + assertTrue(t.threads.isEmpty()) + } + + @Test + fun `ticket detail carries per ticket permissions`() { + val t = gson.fromJson("""{"id":5,"number":"T-1","subject":"S","permissions":{"reply_ticket":true,"close_ticket":false}}""", TicketDetailDto::class.java) + assertEquals(true, t.permissions?.get("reply_ticket")) + assertEquals(false, t.permissions?.get("close_ticket")) + } + + @Test + fun `activity response reports health`() { + val a = gson.fromJson("""{"newReplies":2,"newUnassigned":1,"serverTime":"2026-10-15 10:00:00","health":[{"level":"warning","code":"fetch_stale","message":"Abruf veraltet"}]}""", ActivityResponseDto::class.java) + assertEquals(2, a.newReplies) + assertEquals("fetch_stale", a.health.single().code) + } + + @Test + fun `saved views parse filters`() { + val v = gson.fromJson("""{"items":[{"id":1,"name":"Überfällige","isShared":true,"mine":false,"ownerName":"Eva","filters":{"status":"open","sla":"overdue"}}]}""", SavedViewListResponse::class.java) + val view = v.items.single() + assertEquals("Überfällige", view.name) + assertTrue(view.isShared) + assertFalse(view.mine) + assertEquals("overdue", view.filters["sla"]) + } + + @Test + fun `sla plans parse`() { + val p = gson.fromJson("""{"items":[{"id":2,"name":"Premium","responseHours":2,"resolutionHours":8,"isActive":true,"departmentCount":1,"helpTopicCount":3}]}""", SlaPlanListResponse::class.java) + assertEquals(8, p.items.single().resolutionHours) + assertEquals(3, p.items.single().helpTopicCount) + } + + @Test + fun `report response parses rows and totals`() { + val r = gson.fromJson("""{"config":{"dimension":"department"},"columns":[{"key":"label","label":"Abteilung"},{"key":"total","label":"Tickets"}], + "rows":[{"label":"Technik","total":42}],"totals":{"total":42},"dimensions":[],"metrics":[],"saved":[{"id":1,"name":"Monat","isShared":true,"mine":true}]}""", ReportResponseDto::class.java) + assertEquals(2, r.columns.size) + assertEquals("Technik", r.rows.single()["label"]) + assertEquals("Monat", r.saved.single().name) + } + + @Test + fun `error envelope falls back to defaults`() { + val e = gson.fromJson("""{"error":{"code":"FORBIDDEN","message":"Keine Berechtigung."}}""", ErrorEnvelope::class.java) + assertEquals("FORBIDDEN", e.error.code) + assertEquals("UNKNOWN", gson.fromJson("""{"error":{}}""", ErrorEnvelope::class.java).error.code) + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/ui/ComposeUiTest.kt b/mobile/src/test/java/de/deskhub/staff/ui/ComposeUiTest.kt new file mode 100644 index 00000000..04cc011a --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/ui/ComposeUiTest.kt @@ -0,0 +1,88 @@ +package de.deskhub.staff.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import de.deskhub.staff.domain.model.HealthIssue +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StatusOption +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Compose-Oberflächentests auf der JVM (Robolectric) – kein Emulator nötig. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ComposeUiTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun offlineBannerAppearsAndRetryWorks() { + var retried = 0 + rule.setContent { + MaterialTheme { StatusBanners(DeskHubState(offline = true), onRetry = { retried++ }) } + } + rule.onNodeWithText("Keine Verbindung zum Server", substring = true).assertIsDisplayed() + rule.onNodeWithText("Erneut").performClick() + assertEquals(1, retried) + } + + @Test + fun noBannerWhenOnlineAndHealthy() { + rule.setContent { MaterialTheme { StatusBanners(DeskHubState(offline = false), onRetry = {}) } } + rule.onNodeWithText("Keine Verbindung zum Server", substring = true).assertDoesNotExist() + rule.onNodeWithText("Störung", substring = true).assertDoesNotExist() + } + + @Test + fun serverHealthIssuesAreShownAsFaultOrHint() { + rule.setContent { + MaterialTheme { + StatusBanners( + DeskHubState(healthIssues = listOf(HealthIssue("error", "Postfach nicht erreichbar"), HealthIssue("warning", "Abruf veraltet"))), + onRetry = {}, + ) + } + } + rule.onNodeWithText("Störung: Postfach nicht erreichbar").assertIsDisplayed() + rule.onNodeWithText("Hinweis: Abruf veraltet").assertIsDisplayed() + } + + @Test + fun statusPillShowsLabelFromMetadata() { + val staff = Staff(id = 1, name = "Max", email = "m@x.de", role = "agent", permissions = emptyMap()) + val meta = Metadata(staff, emptyList(), emptyList(), emptyList(), listOf(StatusOption("open", "Offen", "#3b82f6"))) + rule.setContent { + MaterialTheme { + StatusPill("open", meta) + StatusPill("custom_state", meta) + } + } + rule.onNodeWithText("Offen").assertIsDisplayed() + rule.onNodeWithText("custom_state").assertIsDisplayed() // unbekannter Status: Rohwert statt Absturz + } + + @Test + fun themeMenuOffersAllModesAndReportsSelection() { + var chosen: ThemeMode? = null + rule.setContent { + MaterialTheme { + CompositionLocalProvider(LocalThemeController provides ThemeController(ThemeMode.System) { chosen = it }) { ThemeMenuButton() } + } + } + rule.onNodeWithContentDescription("Design wechseln").performClick() + listOf("Wie System", "Hell", "Dunkel").forEach { rule.onNodeWithText(it).assertIsDisplayed() } + rule.onNodeWithText("Dunkel").performClick() + assertTrue(chosen == ThemeMode.Dark) + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/ui/PermissionsTest.kt b/mobile/src/test/java/de/deskhub/staff/ui/PermissionsTest.kt new file mode 100644 index 00000000..a52da301 --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/ui/PermissionsTest.kt @@ -0,0 +1,27 @@ +package de.deskhub.staff.ui + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PermissionsTest { + @Test + fun `ticket permission overrides global permission`() { + // Globales Recht vorhanden, aber in der Abteilung dieses Tickets nicht erlaubt + assertFalse(ticketPermission(mapOf("reply_ticket" to false), mapOf("reply_ticket" to true), "reply_ticket")) + // Globales Recht fehlt, aber die Abteilungsrolle erlaubt es + assertTrue(ticketPermission(mapOf("close_ticket" to true), mapOf("close_ticket" to false), "close_ticket")) + } + + @Test + fun `falls back to global permissions for older servers`() { + assertTrue(ticketPermission(null, mapOf("post_note" to true), "post_note")) + assertFalse(ticketPermission(null, mapOf("post_note" to false), "post_note")) + } + + @Test + fun `unknown permission is denied`() { + assertFalse(ticketPermission(emptyMap(), emptyMap(), "assign_ticket")) + assertFalse(ticketPermission(null, null, "assign_ticket")) + } +}