From b114c115d7bac9e0de5acef0d14b6864e0e3d0a1 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 17 Feb 2026 15:51:23 +0100 Subject: [PATCH] Erweiterungen --- AGENTS.md | 3 + .../controller/LocationApiController.java | 7 +- .../controller/MessageController.java | 10 +- .../votianlt/dto/AppLoginRequest.class | Bin 0 -> 2046 bytes .../votianlt/dto/AppLoginResponse.class | Bin 0 -> 2209 bytes .../votianlt/dto/AppLoginResponse.java | 5 +- .../dto/ChatMessageInboundPayload.class | Bin 0 -> 4225 bytes .../dto/ChatMessageOutboundPayload.class | Bin 0 -> 3175 bytes .../votianlt/dto/ClientMessageSummary.class | Bin 0 -> 3793 bytes .../votianlt/dto/JobWithRelatedDataDTO.class | Bin 0 -> 3121 bytes .../votianlt/messaging/MessagingConfig.java | 4 +- .../votianlt/messaging/WebSocketService.java | 4 +- .../model/AddressValidationResult.java | 53 + .../votianlt/model/InvoiceTemplate.java | 22 +- .../java/de/assecutor/votianlt/model/Job.java | 10 +- .../votianlt/model/LocationPosition.java | 4 +- .../votianlt/model/MessageDeliveryStatus.java | 7 +- .../model/RouteCalculationResult.java | 44 + .../de/assecutor/votianlt/model/Service.java | 138 + .../votianlt/pages/service/AddJobService.java | 4 +- .../service/AddressValidationService.java | 292 +++ .../votianlt/pages/view/AddJobView.java | 697 ++++- .../pages/view/CreateInvoiceView.java | 456 ++++ .../votianlt/pages/view/EditProfileView.java | 926 ++++--- .../pages/view/InvoiceGeneratorView.java | 265 +- .../votianlt/pages/view/JobSummaryView.java | 2252 ++++++++--------- .../votianlt/pages/view/MessagesView.java | 47 +- .../votianlt/pages/view/ShowJobsView.java | 32 +- .../votianlt/pages/view/StartView.java | 3 +- .../repository/AppUserRepository.class | Bin 0 -> 930 bytes .../repository/CommentRepository.class | Bin 0 -> 983 bytes .../repository/InvoiceTemplateRepository.java | 6 +- .../repository/JobHistoryRepository.class | Bin 0 -> 1962 bytes .../votianlt/repository/JobRepository.class | Bin 0 -> 3115 bytes .../votianlt/repository/PhotoRepository.class | Bin 0 -> 904 bytes .../repository/ServiceRepository.java | 13 + .../votianlt/security/SecurityConfig.java | 3 +- .../service/CustomerInvoiceService.java | 75 +- .../service/InvoiceTemplateService.java | 12 +- .../votianlt/service/LocationService.java | 13 +- .../votianlt/service/MessageService.java | 52 +- 41 files changed, 3666 insertions(+), 1793 deletions(-) create mode 100644 src/main/java/de/assecutor/votianlt/dto/AppLoginRequest.class create mode 100644 src/main/java/de/assecutor/votianlt/dto/AppLoginResponse.class create mode 100644 src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.class create mode 100644 src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.class create mode 100644 src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.class create mode 100644 src/main/java/de/assecutor/votianlt/dto/JobWithRelatedDataDTO.class create mode 100644 src/main/java/de/assecutor/votianlt/model/AddressValidationResult.java create mode 100644 src/main/java/de/assecutor/votianlt/model/RouteCalculationResult.java create mode 100644 src/main/java/de/assecutor/votianlt/model/Service.java create mode 100644 src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java create mode 100644 src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java create mode 100644 src/main/java/de/assecutor/votianlt/repository/AppUserRepository.class create mode 100644 src/main/java/de/assecutor/votianlt/repository/CommentRepository.class create mode 100644 src/main/java/de/assecutor/votianlt/repository/JobHistoryRepository.class create mode 100644 src/main/java/de/assecutor/votianlt/repository/JobRepository.class create mode 100644 src/main/java/de/assecutor/votianlt/repository/PhotoRepository.class create mode 100644 src/main/java/de/assecutor/votianlt/repository/ServiceRepository.java diff --git a/AGENTS.md b/AGENTS.md index 5c83491..1d9814a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,3 +17,6 @@ History currently uses brief German titles; shift to imperative, scoped summarie ## Security & Configuration Tips External service credentials for MongoDB, SMTP, and MQTT belong in environment variables or a developer-specific `application-local.properties` kept out of version control. Document default ports and topics when touching `MqttConfig` so ops can replicate environments. For two-factor flows, keep shared secrets in secure storage and avoid logging codes during development. + +# Misc +Never start the application; leave that to the user. \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/controller/LocationApiController.java b/src/main/java/de/assecutor/votianlt/controller/LocationApiController.java index 62d780a..165bcc1 100644 --- a/src/main/java/de/assecutor/votianlt/controller/LocationApiController.java +++ b/src/main/java/de/assecutor/votianlt/controller/LocationApiController.java @@ -27,13 +27,14 @@ public class LocationApiController { /** * Gibt die aktuelle Position eines App-Nutzers zurück. * - * @param appUserId die ID des App-Nutzers + * @param appUserId + * die ID des App-Nutzers * @return die aktuelle Position oder 404 wenn keine vorhanden */ @GetMapping("/{appUserId}") public ResponseEntity getCurrentPosition(@PathVariable String appUserId) { LocationPosition position = locationService.getLatestPosition(appUserId); - + if (position == null || position.getLatitude() == null || position.getLongitude() == null) { return ResponseEntity.notFound().build(); } @@ -44,7 +45,7 @@ public class LocationApiController { response.setAccuracy(position.getAccuracy()); response.setSpeed(position.getSpeed()); response.setTimestamp(position.getTimestamp()); - + return ResponseEntity.ok(response); } diff --git a/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/src/main/java/de/assecutor/votianlt/controller/MessageController.java index 35a5e99..731f6e6 100644 --- a/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -36,8 +36,8 @@ import java.util.Optional; import org.bson.types.ObjectId; /** - * Message controller for handling real-time communication with apps. - * Provides endpoints for sending and receiving messages via WebSocket. + * Message controller for handling real-time communication with apps. Provides + * endpoints for sending and receiving messages via WebSocket. */ @Component @Slf4j @@ -409,9 +409,9 @@ public class MessageController { /** * Handle incoming message from a client via WebSocket. Client sends to - * /server/message with payload: { "content": "message payload", - * "contentType": "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": - * "optional job number" } + * /server/message with payload: { "content": "message payload", "contentType": + * "TEXT|IMAGE", "jobId": "optional job id", "jobNumber": "optional job number" + * } * * The appUserId is determined from the authenticated WebSocket session. */ diff --git a/src/main/java/de/assecutor/votianlt/dto/AppLoginRequest.class b/src/main/java/de/assecutor/votianlt/dto/AppLoginRequest.class new file mode 100644 index 0000000000000000000000000000000000000000..ff8c88bfa52bd7905e7f40fbb932a67ec762f7f4 GIT binary patch literal 2046 zcma)7*>W326g@4EG_o|_ojAh8IN&7K;tWe5!Lk9z*kH;wsW^nH@IqT^q_IZb%1p~4 zf0E(@;uk+a6$w>&px`B`;+wK?TN+!kES8cq-F@dS=iENs`s>xx-vO*(Z4faG*svVL zkzmM{h0DWGl$u%v?!M9%lyotB*Eo+j7{wSl zSAgJtDP?L$ zHG`5^_YCUE9{pS~yTp)*$lU2e<=SRR)!iR>s73H&-FNp0QmCZMkNLV^(R!mIRUmE# zsv!dHiIDgXc`e&FvL@AiIY4{JNEbJh(xDD`V@v3&Du+p2XPE23HQ!S8GAqm;>2{fA zM-o^}p@=0L%SH^eNpl^Qd2(OXM5O(;KGu1O4$uRJhuz(Z9NXo;#CQDP+|*=RLHs!f^2vr+!dvtTN2} zyDO==%?lUk%uUb+#OOm;OlwB>0*xuqo?wjZmuPjKR*c4Y?pLs%X+lb)XQE{s!hAH( zw%*VQ?h|JBh7I?G<&clUW@wC=l)3z4uwPn9JLQr+afSYI_J76EbrjWmM59ers`=a_ z#I3{ppNRd0_`qTQ55%?#xhF`qmNXgOF3@AqT6m6h#(IXa12_vA>j}n>Jf;@o`>M5Mm~Bmu7ORElNRH+YU=xQozBl%%ZDS|C zZTmvYKHZ}}y1ygF9U1?Iw|n(5*i&{6ZWMNzMsrP#DI|vX l@IH-c!uiW>g$J0}BWG)S*LcT~ z`cHrt-pETHKx&HA2Luna62FO3?%3{@oCsDP%4Sp|&nPUkaMbj$T9S?5i}aE>8dN0Y&i;K-lg0F&0-rarfJHjOa;rlYfz)8{f{BYrFhm%$EmrYzz$C`93i7_Oe#H%J&ag~T4aQC3()G2VHQjU|t zubW6A&2XX3o1)~{H7>ufB!O&Ka$AygH&T0(ye2LGLOyFEhdj|-w`@y((7*U|27SO{ ze#f%KZm+#By7%~gi+CmP_H1djMcF>IT#HPbw(UqREh41S)pFYVPP1@Z*rLm&s252^ zSIAR|_0(RP&R4cmWyg}_39CRRflhT|n5{})YgTy2qY{?R0L+m3pBku}t4^<56I+&w zX3<}h*Hi*2k2f7hy0XhV6(J9tx*G;bbKQ?ldG))&a+kf5RZ~99?FLKFhwxF9`sAj8 zl1k;r46A2FV%djIQ+Sm<$nF-eQRh5h_;$2(J>Y=NHn;07;jZpDPP5l3PUd>VGUj;i zK}Sr1e>s95_Q;6IJEFu}t*RvjWz;O&l-^QHHM(oyHpA74n5I!RP-a;EZzz@GATH_^ zjk*g6&`9qVnrZ6w&>yM$X*9u96O;WKtuE1u(HP7;1Use)LxKK7ed7$?@aAv!cc{Oo zxEVkB+)SSv_3sq78Nb_{&+RQ-_uQ6g)D)9U@&xQ>-*tx1?`^y@;g>yu@gl!>r~A#k z$d6uP-hB^gG>Ba@@^VXnWVNjyi-4{DDVOS`mMTGR7S zF_X}r;M@_+d_sSOmxl0_wID%O)DyO!m7PQw*zfoL660jZujGy};Sh+t$79GsLKcKx3*fynkB48EP%nFmV-_3WlDQ$9N6!*Rx+D z`eckKJ7~IJknxvS*mrdfH$1nTr|d$8-1@DM$x&Tr@+X)m(#Y5;0jv>a4MnWe7^VO| Sq-O)SXceW|7Pj#T68`{jX{|^A literal 0 HcmV?d00001 diff --git a/src/main/java/de/assecutor/votianlt/dto/AppLoginResponse.java b/src/main/java/de/assecutor/votianlt/dto/AppLoginResponse.java index 8cb614c..2b62be5 100644 --- a/src/main/java/de/assecutor/votianlt/dto/AppLoginResponse.java +++ b/src/main/java/de/assecutor/votianlt/dto/AppLoginResponse.java @@ -10,6 +10,9 @@ import lombok.NoArgsConstructor; public class AppLoginResponse { private boolean success; private String message; - /** Only populated on success, for internal server-side routing. Not sent to client. */ + /** + * Only populated on success, for internal server-side routing. Not sent to + * client. + */ private String appUserId; } \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.class b/src/main/java/de/assecutor/votianlt/dto/ChatMessageInboundPayload.class new file mode 100644 index 0000000000000000000000000000000000000000..a702ac60ecc36b53ee7e8b0ff94f8ad3d779a9a4 GIT binary patch literal 4225 zcmbVOYgZfB72OvEBg6=>j4?7HC{8dSf#o<&OfAQ;0Tas+I5pS}xUZ2k2-(t%JToG8 z(?^;%>AOweP22DJ(hsDoJZi5t-;%ZZo4nfYJ2Nr@26x$OF>}S-bI;lPyzcq;fA0Sa zz(xEef)=zY2&rg8yFkw^ZB0v8G^3QhrWZ`BDA0bcY?Phz0<{ftP}h7acP_y{b9B<*bo6>qhZS?Ow&y$Rup(1--ncTLQ7%4$%e2DjOx& zM6Ze?QomC0KufbnRrE`)o&GmTM1DV}VgSzwwB0iES!$2xOska6+oq9rC}O9t=5Of* zCtGwiKdWL$YVM%)e7&02Ed|dB9N&SFtyJ`qR=H@E>Q(yd(%phybIPU>#`6OGHIGE1 zTDP5qVLFMto-pc_N(2cE%ix_5I5N7Q)!2rR=W(`uOjUHd4!HX(R<0XNpORAxWiBr-YffLQ>Jl|x-nl$>K zB-WQzyn-`~l8<#)3e5!S2~LQE5-}6TfHZVgMF!^tx_v{9kO{mNV5JGHpEF|z#ypH6 zY2Y;#7jSXM>sQ^7YL$l9|DRKQJ5trOjPi`Yd(FuF{r$+Ew7?D|vS0PsjR(N>mDLDx_>ziiSYS<-?Mu~~bI)x= z%VAs>7?>;DHfuLw`N^DEDeIMDVr1AJkw?F*;w>z(=vGXtsyU9%G94Mc*+eNI4}F9R zZVDVdprj*M#&8&46G+WIvgp+&>rzXsX_ay@F=rYjb7m^h$SPT@-;hz%1d@-B;^L}h zzOCgedPaIXhk}ZtbX2EfUa!@3Yg)7Q2z&90iV{}o99pdFS62jfm50w|>`AjBj9UWF zTsQ1`t!7&ETcWA?x$cee`l!hGR|T@82k3unKi*aRK2WLER8hknrqHTpuTJwO5tw{R z16irhR^gz|dyF}vRcwJPPp2&)YnFMTc?i36WkdJ+`l1XQo8W?@6>hVU`v3RL>CURj z%gEBIx}#eJ(z#-p)kn9}te+8$zDfTCH?_xmmD#>fE*Y9rw}_&$N9nmH7QWA$zWCK} z{;5qz8M7;9-tE_S+4FBco%tq6k1}}YqVzwxeIUE%)@#%=BXgR4C)j}p-U{p}Jaj$osa1y`v2=IdJk< zOb&+b%3;u5IsCaR$2zXv;3S9{o?7F7b*}AP$CFzS6Q4nu_!#YfgEF5?Z6KN)-@xJI z#0L73@z4h1$#~la20x*E3*P2Ch7jLfe0QUVYl`o?{4#`=|I&E7f-VI)_b;qqnLmIj zg}1>y3LR;~p?A6P>E|=RXNXS+zKM5ybRR--(JdrDNAv*_OR?iyc;VAzY-Agwxnyjd z%Um)x@eiDA!8X1ypNvhqzhjfz_~O}6GWM#!b^dJI19UB=#wX&Tc-t1H*3lqwoOpo4 zB+*L>N9n$PIxLP+9K#t-&NCQvNwq?B;al#pZ_|h0!2;jXho`s-uF>S*5vL17A0yQI zS65IvsomXt&ehR|md*<-PdVZG9N!^F>BY0jl#6w4{4+$yH*skbZ%~n#XH+5+n|Sjh z^t&!t4E+gV+8>%Zd@P9?W-es0_~@&L~8FPe~kYOx);x6uhwlMGpsw z_TxPtL$lVtAkhJQKR`4dBsvrbU~s=^2tOc^7QBxSxF#4BuN!*3(CdU=AN0E54=L44 g6u)FPbm2$%34V&7;pg}Te$SlPx# literal 0 HcmV?d00001 diff --git a/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.class b/src/main/java/de/assecutor/votianlt/dto/ChatMessageOutboundPayload.class new file mode 100644 index 0000000000000000000000000000000000000000..8f77ce960cf55746431b42a31729546fba953e4a GIT binary patch literal 3175 zcmbVO>vGdZ7(EL~e949wH&6_L1VU0r0SuHbWB2KTH%WdpYzq;k@tMpvRBLckUbKk`h-;7 zv3+IxdTp;n9&ZQ?M{CPZHceCNJl>)~aO-%=Lq^ z7#Ift1&M<=L@v=)LN2rYplp#iEO7)AbVuD)rmq@TeSt#g4&Q32Qq`%O&Fg%#%zweD zMkTWj_w(#rA4@FZo)e9!*Bmxq*K8?YxjcFJk?XXg+!y#_Cmt_#Q2Hr3cmBh@!Q7|_?2G4T zjCb~)({}6XhNZbq>^wD2=~STix??GK&2fCscg>CPeqI`D0*g^Qt~AQgkm%|l^_;MQ z$9i^otM`_Jo~nOC_my?;9OVR}j8JlvG0FkTIAxMDMVT2Lf|rGlb_$yk-{4!`qnEdC z+YTB*c;$#IdswYYU*K8XHI}{UJXV}rI%qddyU|qM@v7rIZf}%hk&i}YY$e*L?|EH$ zb=TV9Opb4vZ*o+}7b1*@+d<-|zf>Sm;MkxlwyoR%Qh6zSFEHJ^^DFLV==d%)?5MWTMr ze|M>~)EA8B5XM{wDdR*3BgV-NazG1XFWK*`I=IsWbhTf=C+W?Z~$=P52PO$zr&!M zro2sgmr|y@PnoCOpj4h?;a6gWhvzZC=REe(B}XyFaiO1=P>Rqes5QNm@q$-IZ)2DB zUiLaO!FU3&>FFda)WJ%}p_rU#NtkKkH@>3EXqvBYalCYeqrnocE|7Ew(gFta^i z1E@1ZyU>85zQDu_YpGjFx{kss3Tr5=ps;?z>IrM-5$(o#q|5zjtir-N9;1mCY&h7! Q6Fj9wmKNXP2mFM>zXz%UJOBUy literal 0 HcmV?d00001 diff --git a/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.class b/src/main/java/de/assecutor/votianlt/dto/ClientMessageSummary.class new file mode 100644 index 0000000000000000000000000000000000000000..74d0b62c10dc13b0928b982f0f04901ae29b55ef GIT binary patch literal 3793 zcmbtXOLyB;5dN-XTZ$v6NgB5m=?l_^#7=4m1c;LqTK8p|q@_t)Xn9nz)kKXf1xpU6 ze}FYhV8gE6bOGm(!s&tooAw<30Y8I1rOZfjEX#r{I48=zGxL2{b7$txm4E;H$u9uT z!0tr?Jra@%Qb-G&s_8||arA1_wHw84+ch-Hbc;3DE|yJ0x7;P&akO=PrCG0Qjr#&} z)l*k$0z-@Uv~8_uYSwyj#cdeY`W&w=E9m8=Gd^-ztLv=pQ=qWA&sU$XYlg}Ceg*q5 zAdq!!S2KgxIRc`>)*%J^+1l5%8oE|1+fB=5eNMpv92D4RYEH|?bDFCQsJ>UOQP+!$ zc2zT3y-JejoT}g@_F^c+yV}sV4gHG@EqhqO5gg@RYS#JBo0=(*8xL*s%Gy1>>dsBv zV(@VVFXI(~fpy(o?R36pe4@gT6ADf;q@N(y+dE*>s|v<3K|3RpA8L;2@c2aNbwN}! zC}2{;l!8<2+(0|*zCQwKot<@f-PaVn&g*7(*X0bbWQO>Zf98}Cq9 z$g~)Z=t#?_n3yuTq+pKM&2^G@!&<YvUsSFKOFu6gBu2MN1gg2&Ac#&KuC#VqeYsJ;7n@ieO5CGY=TXk^ve&0p)&#zcV~kgWX1O3H zkNa~Dd|UFu&SFRNV@0Unu^4Js+|MGaAdm}UVqY4y40PO;u+E2JL*PWaT^QE3y{QMy z+CLu`v?_IeYV2Q!104YU4PMf$nyEV{7Hxa8xi!}vzL2JaxO#s}kAdF~;ad$gqzaZO zYo@tkxH=WLQn9Gg%M=4gcO+~Iob1|DoU0P*0!RNFN~hA_7xnT4^;tlok?uyCCHNxq zM!LDYk!~t)q&vwQ=??Nnx>INzhTxt~yd{#3kR)hK<$r|uo+bjz^grz>2XQ5sU-c~1 zvSVmclr|KhQ9qBNWumnG5gJ`vF|_QXh?Bz+8eL^Ew81DX7opM35WDYXR*jjR}r&zOV!FLu7MGgtrpUllJ1R#`7|J@vh@Wm!Bg$$nM3v8P6Ns zi}xWu>Utl=Wq2>%t@!;N+KWe@D*pQf=Oate|Fe*PfK>8P;Ws3{MXKjf;a4P&blnFq+Y9<7lE%gy-wxpBQ!UeMDealkyCzB=u}0!U`3EvDsN>S_nS`L& zL5LUVX^RP#_ahmY+la8n1LcrvgIc&`R2e|i9ndzqTOSalD51@XH4&{N!2%!e_QD0q z6W=N)IVbcK>6xbI7(Ju(%>0aZX_$S2^FXv$Mhk$xN<($yUsgY%5OJ_B)=S_s+@VpX Sy;kYbV33rd**%!Bp#B5#i~1t~ literal 0 HcmV?d00001 diff --git a/src/main/java/de/assecutor/votianlt/dto/JobWithRelatedDataDTO.class b/src/main/java/de/assecutor/votianlt/dto/JobWithRelatedDataDTO.class new file mode 100644 index 0000000000000000000000000000000000000000..75c8643f49c4ee5433bb0a2195def485a4bdb8b7 GIT binary patch literal 3121 zcmb7G%TgOh6g@3Qnn4Ukydx_HJH`%3!b(hDmcTX-V?zWaK;p#Nq$MUWFw9_0iz(76m{RUtf584qy zn+ioo6fuU}hLPj0Ym}?PspR$@VRG9Nxeeju7M;?4Q*5mo78l0G92b0UeTAWI+bJIva*1sRijs)6tGNL%hr@o6dqTc3p-u4uZ#L+Uy@B{C|wyW_@* z+odCcZU(K)?K=;v++ye-tx+v*Z{}7?+eTRw#vVv;uZ}+SGsHFxLCzU2kB+@I!Z-%; zmWrf~^U_hm3o9@;O=n2QUN~>-xPT#sPVz7t$fk!Jh4PUBUDR<&!Zm`g*HRk9WgR29 zLdGrbZq1TXq~_?@LLBd6RK=K%G%_dRSQ8b~-egesxm7h*zGJxBN`inYHAbO_iaoC5 z8gdMSyL`u(b?h=1vYym)1-AuNVlg%JAw3+m7JEB&8U7ik7oma&~fe4cn;5tw6C9nyP-5Om3Z#-JDCz z_1F2x=&1_IjW|X%=p_8Hnv_(pU-`x+l&HzD_05x#bd8*NYn|ct*=7nS=!Lu{czI`u z@A*~Igj3%ReRchUE0AIC^;(3MP$4{)jvt?Snm3mzEa>yP$~7VvKZ`1;KdF!@u=a?4sXg+V)5U$ALA&sy^Gb5&5?uQcVysnOBo+I>eu8&F}a>{%nX(cfTL^U(~i!wBKjaKO(9eX8u6r zF`{jUncopv%BG*-oHwLS&vKT2^2+!NbR?DM=stixo>ZP-ppHo8qXbFiW67w*#Ga$) z0G-KbG8O<0=T!m?=e49NL7ERzlUg0598y4$K+zMJBTELls{Mm=Dk3j2pkk2zH1<+H zXJofM*gcW2A|F+LzH)KNIz?ro(ob-4h08<_7oYYwwNXN^D3el&rBtG@m3;v%kvV`& z=bdk*J+&^)p9(H$Z;r(wP6-6^FQ#PHzv8M)vepb!^9)%ayjQ{^((}ll ztg*45;=S-v?GD9o9e4dLk|nxAG4!R0=54QZ_6TZv{0L18zQ^CF_fE{#h6UnBu!v8o W)hMH7`Y&RgM)Dl`0{8JXlK%k authResponse = Map.of( - "success", true, - "message", response.getMessage(), + Map authResponse = Map.of("success", true, "message", response.getMessage(), "locationTrackingEnabled", true); byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse); webSocketService.sendToClient(appUserId, "auth", responseBytes); diff --git a/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java b/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java index fcb9aed..2ddd714 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java +++ b/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java @@ -255,8 +255,8 @@ public class WebSocketService extends TextWebSocketHandler { } /** - * Register a pending session as authenticated under the given appUserId. - * Called by MessagingConfig after successful login. + * Register a pending session as authenticated under the given appUserId. Called + * by MessagingConfig after successful login. */ public void registerAuthenticatedSession(String wsSessionId, String appUserId) { PendingSession pending = pendingSessions.get(wsSessionId); diff --git a/src/main/java/de/assecutor/votianlt/model/AddressValidationResult.java b/src/main/java/de/assecutor/votianlt/model/AddressValidationResult.java new file mode 100644 index 0000000..d094a4f --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/AddressValidationResult.java @@ -0,0 +1,53 @@ +package de.assecutor.votianlt.model; + +import lombok.Data; + +/** + * Speichert das Ergebnis einer Adressvalidierung. Wird verwendet, um zu merken, + * ob eine Adresse bereits validiert wurde und ob sie gültig ist. + */ +@Data +public class AddressValidationResult { + + private final String addressType; // "pickup" oder "delivery" + private final String street; + private final String houseNumber; + private final String zip; + private final String city; + + private boolean valid; + private String formattedAddress; + private double latitude; + private double longitude; + private String validationMessage; + + public AddressValidationResult(String addressType, String street, String houseNumber, String zip, String city) { + this.addressType = addressType; + this.street = street; + this.houseNumber = houseNumber; + this.zip = zip; + this.city = city; + this.valid = false; + } + + /** + * Erstellt einen eindeutigen Schlüssel für diese Adresse + */ + public String getAddressKey() { + return String.format("%s|%s|%s|%s|%s", addressType, normalize(street), normalize(houseNumber), normalize(zip), + normalize(city)); + } + + private String normalize(String value) { + return value != null ? value.trim().toLowerCase() : ""; + } + + /** + * Prüft, ob diese Validierung für die angegebenen Adressdaten gilt + */ + public boolean matches(String street, String houseNumber, String zip, String city) { + return normalize(this.street).equals(normalize(street)) + && normalize(this.houseNumber).equals(normalize(houseNumber)) + && normalize(this.zip).equals(normalize(zip)) && normalize(this.city).equals(normalize(city)); + } +} diff --git a/src/main/java/de/assecutor/votianlt/model/InvoiceTemplate.java b/src/main/java/de/assecutor/votianlt/model/InvoiceTemplate.java index dc4de58..a876434 100644 --- a/src/main/java/de/assecutor/votianlt/model/InvoiceTemplate.java +++ b/src/main/java/de/assecutor/votianlt/model/InvoiceTemplate.java @@ -9,48 +9,48 @@ import org.springframework.data.mongodb.core.mapping.Document; import java.time.LocalDateTime; /** - * Stores invoice template data for a user. - * Contains the JSON representation of the canvas elements. + * Stores invoice template data for a user. Contains the JSON representation of + * the canvas elements. */ @Document(collection = "invoice_templates") @Data @NoArgsConstructor @AllArgsConstructor public class InvoiceTemplate { - + @Id private String id; - + /** * The user ID this template belongs to */ private String userId; - + /** * Template name (optional, for future use if multiple templates are supported) */ private String name; - + /** * JSON string containing the template data (canvas elements) */ private String templateData; - + /** * When the template was created */ private LocalDateTime createdAt; - + /** * When the template was last updated */ private LocalDateTime updatedAt; - + /** * Version for optimistic locking */ private Long version; - + public InvoiceTemplate(String userId, String name, String templateData) { this.userId = userId; this.name = name; @@ -58,7 +58,7 @@ public class InvoiceTemplate { this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); } - + public void updateTemplate(String templateData) { this.templateData = templateData; this.updatedAt = LocalDateTime.now(); diff --git a/src/main/java/de/assecutor/votianlt/model/Job.java b/src/main/java/de/assecutor/votianlt/model/Job.java index f9da10e..52d0166 100644 --- a/src/main/java/de/assecutor/votianlt/model/Job.java +++ b/src/main/java/de/assecutor/votianlt/model/Job.java @@ -133,6 +133,14 @@ public class Job { @Field("price") private BigDecimal price; + // Gefahrene Kilometer für Rechnungsstellung + @Field("kilometers_driven") + private Integer kilometersDriven; + + // Arbeitszeit in 15-Minuten-Einheiten für Rechnungsstellung + @Field("time_in_15min_units") + private Integer timeIn15MinUnits; + /** * Returns the ObjectId as string for JSON serialization. This ensures that the * job id is returned as a string when jobs are retrieved via API. @@ -141,4 +149,4 @@ public class Job { public String getIdAsString() { return id != null ? id.toString() : null; } -} +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/model/LocationPosition.java b/src/main/java/de/assecutor/votianlt/model/LocationPosition.java index 834e580..adb75e5 100644 --- a/src/main/java/de/assecutor/votianlt/model/LocationPosition.java +++ b/src/main/java/de/assecutor/votianlt/model/LocationPosition.java @@ -77,8 +77,8 @@ public class LocationPosition { @Indexed(expireAfter = "3600s") // TTL index: auto-delete after 60 minutes private Instant receivedAt; - public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, - Double altitude, Double speed, Double heading, Instant timestamp) { + public LocationPosition(String appUserId, Double latitude, Double longitude, Double accuracy, Double altitude, + Double speed, Double heading, Instant timestamp) { this.appUserId = appUserId; this.latitude = latitude; this.longitude = longitude; diff --git a/src/main/java/de/assecutor/votianlt/model/MessageDeliveryStatus.java b/src/main/java/de/assecutor/votianlt/model/MessageDeliveryStatus.java index c03541c..f2cdd27 100644 --- a/src/main/java/de/assecutor/votianlt/model/MessageDeliveryStatus.java +++ b/src/main/java/de/assecutor/votianlt/model/MessageDeliveryStatus.java @@ -1,12 +1,11 @@ package de.assecutor.votianlt.model; /** - * Delivery status for messages sent to clients. - * Tracks whether a message was successfully delivered via WebSocket. + * Delivery status for messages sent to clients. Tracks whether a message was + * successfully delivered via WebSocket. */ public enum MessageDeliveryStatus { - NOTSEND("Nicht gesendet"), - SEND("Gesendet"); + NOTSEND("Nicht gesendet"), SEND("Gesendet"); private final String displayName; diff --git a/src/main/java/de/assecutor/votianlt/model/RouteCalculationResult.java b/src/main/java/de/assecutor/votianlt/model/RouteCalculationResult.java new file mode 100644 index 0000000..aa138ce --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/RouteCalculationResult.java @@ -0,0 +1,44 @@ +package de.assecutor.votianlt.model; + +import lombok.Data; + +/** + * Speichert das Ergebnis einer Routenberechnung zwischen zwei Adressen. + */ +@Data +public class RouteCalculationResult { + + private boolean valid; + private double distanceKm; + private int durationSeconds; + private String formattedDistance; + private String formattedDuration; + private String routeMessage; + + public RouteCalculationResult() { + this.valid = false; + this.distanceKm = 0.0; + this.durationSeconds = 0; + } + + /** + * Gibt die Dauer in Minuten zurück + */ + public int getDurationMinutes() { + return durationSeconds / 60; + } + + /** + * Gibt die Dauer formatiert zurück (z.B. "1 Std. 30 Min." oder "45 Min.") + */ + public String getFormattedDurationLong() { + int hours = durationSeconds / 3600; + int minutes = (durationSeconds % 3600) / 60; + + if (hours > 0) { + return String.format("%d Std. %d Min.", hours, minutes); + } else { + return String.format("%d Min.", minutes); + } + } +} diff --git a/src/main/java/de/assecutor/votianlt/model/Service.java b/src/main/java/de/assecutor/votianlt/model/Service.java new file mode 100644 index 0000000..06f2dbc --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/model/Service.java @@ -0,0 +1,138 @@ +package de.assecutor.votianlt.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import java.math.BigDecimal; + +@Document(collection = "services") +public class Service { + + @Id + private String id; + + private String userId; + private String name; + private CalculationBasis calculationBasis; + private BigDecimal price; // For FLAT_RATE services + private BigDecimal pricePerKilometer; // For DISTANCE services - price per kilometer + private BigDecimal pricePer15Minutes; // For TIME services - price per 15 minutes + private BigDecimal vatRate; + private boolean mandatory; + + public enum CalculationBasis { + DISTANCE, TIME, FLAT_RATE + } + + public Service() { + } + + public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price, + BigDecimal vatRate) { + this(userId, name, calculationBasis, price, vatRate, false); + } + + public Service(String userId, String name, CalculationBasis calculationBasis, BigDecimal price, BigDecimal vatRate, + boolean mandatory) { + this.userId = userId; + this.name = name; + this.calculationBasis = calculationBasis; + this.vatRate = vatRate; + this.mandatory = mandatory; + + // Set the appropriate price field based on calculation basis + switch (calculationBasis) { + case DISTANCE: + this.pricePerKilometer = price; + break; + case TIME: + this.pricePer15Minutes = price; + break; + case FLAT_RATE: + this.price = price; + break; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public CalculationBasis getCalculationBasis() { + return calculationBasis; + } + + public void setCalculationBasis(CalculationBasis calculationBasis) { + this.calculationBasis = calculationBasis; + } + + public BigDecimal getVatRate() { + return vatRate; + } + + public void setVatRate(BigDecimal vatRate) { + this.vatRate = vatRate; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public BigDecimal getPricePerKilometer() { + return pricePerKilometer; + } + + public void setPricePerKilometer(BigDecimal pricePerKilometer) { + this.pricePerKilometer = pricePerKilometer; + } + + public BigDecimal getPricePer15Minutes() { + return pricePer15Minutes; + } + + public void setPricePer15Minutes(BigDecimal pricePer15Minutes) { + this.pricePer15Minutes = pricePer15Minutes; + } + + /** + * Get the appropriate price based on calculation basis + */ + public BigDecimal getEffectivePrice() { + return switch (calculationBasis) { + case DISTANCE -> pricePerKilometer; + case TIME -> pricePer15Minutes; + case FLAT_RATE -> price; + }; + } + + public boolean isMandatory() { + return mandatory; + } + + public void setMandatory(boolean mandatory) { + this.mandatory = mandatory; + } +} diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java index 13ea44f..ea1ff5c 100644 --- a/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java @@ -182,8 +182,8 @@ public class AddJobService { } /** - * Sendet den neu erstellten Job per WebSocket an den zugewiesenen Client, falls dieser - * online ist. + * Sendet den neu erstellten Job per WebSocket an den zugewiesenen Client, falls + * dieser online ist. */ private void notifyClientJobCreated(Job job) { if (!job.isDigitalProcessing()) { diff --git a/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java new file mode 100644 index 0000000..b8ff04e --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/service/AddressValidationService.java @@ -0,0 +1,292 @@ +package de.assecutor.votianlt.pages.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.model.AddressValidationResult; +import de.assecutor.votianlt.model.RouteCalculationResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * Service zur Validierung von Adressen über die Google Geocoding API. + */ +@Service +@Slf4j +public class AddressValidationService { + + @Value("${app.google.maps.api-key:}") + private String googleMapsApiKey; + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + private static final String GEOCODING_API_URL = "https://maps.googleapis.com/maps/api/geocode/json"; + private static final String DIRECTIONS_API_URL = "https://maps.googleapis.com/maps/api/directions/json"; + + public AddressValidationService() { + this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + this.objectMapper = new ObjectMapper(); + } + + /** + * Validiert eine Adresse über die Google Geocoding API. + * + * @param addressType + * "pickup" oder "delivery" + * @param street + * Straße + * @param houseNumber + * Hausnummer + * @param zip + * Postleitzahl + * @param city + * Stadt + * @return AddressValidationResult mit dem Validierungsergebnis + */ + public AddressValidationResult validateAddress(String addressType, String street, String houseNumber, String zip, + String city) { + AddressValidationResult result = new AddressValidationResult(addressType, street, houseNumber, zip, city); + + // Prüfen, ob API-Key konfiguriert ist + if (googleMapsApiKey == null || googleMapsApiKey.isBlank()) { + log.warn("Google Maps API Key nicht konfiguriert. Adressvalidierung übersprungen."); + result.setValidationMessage("API-Key nicht konfiguriert"); + return result; + } + + // Prüfen, ob alle erforderlichen Felder vorhanden sind + if (isEmpty(street) || isEmpty(zip) || isEmpty(city)) { + result.setValidationMessage("Unvollständige Adresse"); + return result; + } + + try { + // Adressstring zusammenbauen + String addressString = buildAddressString(street, houseNumber, zip, city); + String encodedAddress = URLEncoder.encode(addressString, StandardCharsets.UTF_8); + + // URL für die Geocoding API erstellen + String requestUrl = String.format("%s?address=%s&key=%s&language=de®ion=de", GEOCODING_API_URL, + encodedAddress, googleMapsApiKey); + + log.debug("Validiere Adresse: {}", addressString); + + // HTTP Request senden + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl)).GET() + .timeout(Duration.ofSeconds(10)).build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + log.error("Fehler bei der Geocoding API: HTTP {}", response.statusCode()); + result.setValidationMessage("Fehler bei der API-Anfrage"); + return result; + } + + // JSON Response parsen + JsonNode rootNode = objectMapper.readTree(response.body()); + String status = rootNode.path("status").asText(); + + if (!"OK".equals(status)) { + log.warn("Geocoding API Status: {} für Adresse: {}", status, addressString); + result.setValidationMessage("Adresse nicht gefunden: " + status); + return result; + } + + // Ergebnisse extrahieren + JsonNode results = rootNode.path("results"); + if (results.isEmpty()) { + result.setValidationMessage("Keine Ergebnisse gefunden"); + return result; + } + + JsonNode firstResult = results.get(0); + String formattedAddress = firstResult.path("formatted_address").asText(); + + // Geokoordinaten extrahieren + JsonNode geometry = firstResult.path("geometry"); + JsonNode location = geometry.path("location"); + double lat = location.path("lat").asDouble(); + double lng = location.path("lng").asDouble(); + + // Prüfen, ob die Adresse als "ROOFTOP" (genaue Adresse) oder + // "RANGE_INTERPOLATED" gefunden wurde + String locationType = geometry.path("location_type").asText(); + boolean isPrecise = "ROOFTOP".equals(locationType) || "RANGE_INTERPOLATED".equals(locationType); + + // Adresskomponenten prüfen + boolean hasStreetNumber = false; + boolean hasPostalCode = false; + + JsonNode addressComponents = firstResult.path("address_components"); + for (JsonNode component : addressComponents) { + JsonNode types = component.path("types"); + for (JsonNode type : types) { + String typeStr = type.asText(); + if ("street_number".equals(typeStr)) { + hasStreetNumber = true; + } else if ("postal_code".equals(typeStr)) { + hasPostalCode = true; + } + } + } + + // Ergebnis setzen + result.setValid(isPrecise && hasPostalCode); + result.setFormattedAddress(formattedAddress); + result.setLatitude(lat); + result.setLongitude(lng); + + if (result.isValid()) { + result.setValidationMessage("Adresse erfolgreich validiert"); + } else { + result.setValidationMessage("Adresse ungenau gefunden (keine Hausnummer oder Postleitzahl)"); + } + + log.debug("Adressvalidierung erfolgreich: {} -> {}", addressString, formattedAddress); + + } catch (Exception e) { + log.error("Fehler bei der Adressvalidierung", e); + result.setValidationMessage("Fehler: " + e.getMessage()); + } + + return result; + } + + /** + * Baut einen zusammenhängenden Adressstring für die API-Anfrage. + */ + private String buildAddressString(String street, String houseNumber, String zip, String city) { + StringBuilder sb = new StringBuilder(); + sb.append(street.trim()); + if (!isEmpty(houseNumber)) { + sb.append(" ").append(houseNumber.trim()); + } + sb.append(", ").append(zip.trim()).append(" ").append(city.trim()); + sb.append(", Deutschland"); + return sb.toString(); + } + + private boolean isEmpty(String value) { + return value == null || value.trim().isEmpty(); + } + + /** + * Berechnet die schnellste Route zwischen zwei Adressen über die Google + * Directions API. + * + * @param pickupResult + * Validierungsergebnis der Abholadresse (muss gültige Koordinaten + * enthalten) + * @param deliveryResult + * Validierungsergebnis der Lieferadresse (muss gültige Koordinaten + * enthalten) + * @return RouteCalculationResult mit Entfernung und Dauer + */ + public RouteCalculationResult calculateRoute(AddressValidationResult pickupResult, + AddressValidationResult deliveryResult) { + RouteCalculationResult routeResult = new RouteCalculationResult(); + + // Prüfen, ob API-Key konfiguriert ist + if (googleMapsApiKey == null || googleMapsApiKey.isBlank()) { + log.warn("Google Maps API Key nicht konfiguriert. Routenberechnung übersprungen."); + routeResult.setRouteMessage("API-Key nicht konfiguriert"); + return routeResult; + } + + // Prüfen, ob beide Adressen gültige Koordinaten haben + if (pickupResult == null || !pickupResult.isValid() || deliveryResult == null || !deliveryResult.isValid()) { + routeResult.setRouteMessage("Beide Adressen müssen validiert sein"); + return routeResult; + } + + try { + // Koordinaten für Start und Ziel + String origin = String.format("%s,%s", pickupResult.getLatitude(), pickupResult.getLongitude()); + String destination = String.format("%s,%s", deliveryResult.getLatitude(), deliveryResult.getLongitude()); + + // URL für die Directions API erstellen + String requestUrl = String.format("%s?origin=%s&destination=%s&mode=driving&key=%s&language=de®ion=de", + DIRECTIONS_API_URL, origin, destination, googleMapsApiKey); + + log.debug("Berechne Route von {} nach {}", pickupResult.getFormattedAddress(), + deliveryResult.getFormattedAddress()); + + // HTTP Request senden + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(requestUrl)).GET() + .timeout(Duration.ofSeconds(10)).build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + log.error("Fehler bei der Directions API: HTTP {}", response.statusCode()); + routeResult.setRouteMessage("Fehler bei der API-Anfrage"); + return routeResult; + } + + // JSON Response parsen + JsonNode rootNode = objectMapper.readTree(response.body()); + String status = rootNode.path("status").asText(); + + if (!"OK".equals(status)) { + log.warn("Directions API Status: {}", status); + routeResult.setRouteMessage("Route konnte nicht berechnet werden: " + status); + return routeResult; + } + + // Routen extrahieren + JsonNode routes = rootNode.path("routes"); + if (routes.isEmpty()) { + routeResult.setRouteMessage("Keine Route gefunden"); + return routeResult; + } + + JsonNode firstRoute = routes.get(0); + JsonNode legs = firstRoute.path("legs"); + if (legs.isEmpty()) { + routeResult.setRouteMessage("Keine Routeninformationen verfügbar"); + return routeResult; + } + + // Ersten Leg (Hauptstrecke) verwenden + JsonNode firstLeg = legs.get(0); + + // Distanz extrahieren + JsonNode distanceNode = firstLeg.path("distance"); + int distanceMeters = distanceNode.path("value").asInt(); + String distanceText = distanceNode.path("text").asText(); + + // Dauer extrahieren + JsonNode durationNode = firstLeg.path("duration"); + int durationSeconds = durationNode.path("value").asInt(); + String durationText = durationNode.path("text").asText(); + + // Ergebnis setzen + routeResult.setValid(true); + routeResult.setDistanceKm(distanceMeters / 1000.0); + routeResult.setDurationSeconds(durationSeconds); + routeResult.setFormattedDistance(distanceText); + routeResult.setFormattedDuration(durationText); + routeResult.setRouteMessage( + String.format("Route: %s, Dauer: %s", distanceText, routeResult.getFormattedDurationLong())); + + log.debug("Routenberechnung erfolgreich: {} km, {} Min.", routeResult.getDistanceKm(), + routeResult.getDurationMinutes()); + + } catch (Exception e) { + log.error("Fehler bei der Routenberechnung", e); + routeResult.setRouteMessage("Fehler: " + e.getMessage()); + } + + return routeResult; + } +} diff --git a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java index 48c2a71..e94bc0f 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/AddJobView.java @@ -44,6 +44,7 @@ import de.assecutor.votianlt.model.task.CommentTask; import de.assecutor.votianlt.pages.service.AddJobService; import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.AddCustomerService; +import de.assecutor.votianlt.pages.service.AddressValidationService; import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.pages.service.AppUserService; import de.assecutor.votianlt.model.AppUser; @@ -51,10 +52,17 @@ import de.assecutor.votianlt.pages.service.TaskTemplateService; import de.assecutor.votianlt.model.TaskTemplate; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.security.SecurityService; +import de.assecutor.votianlt.model.Service; +import de.assecutor.votianlt.repository.ServiceRepository; +import com.vaadin.flow.component.grid.Grid; +import java.math.BigDecimal; +import java.math.RoundingMode; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import de.assecutor.votianlt.model.CargoItem; +import de.assecutor.votianlt.model.AddressValidationResult; +import de.assecutor.votianlt.model.RouteCalculationResult; import java.time.LocalDate; import java.util.*; import java.util.Objects; @@ -73,6 +81,8 @@ public class AddJobView extends Main { private final AppUserService appUserService; private final TaskTemplateService taskTemplateService; private final SecurityService securityService; + private final ServiceRepository serviceRepository; + private final AddressValidationService addressValidationService; // Customer selection private ComboBox customerSelection; @@ -108,8 +118,12 @@ public class AddJobView extends Main { private Checkbox digitalProcessing; private ComboBox appUser; - // Price field - private TextField price; + // Services for the job + private Grid servicesGrid; + private final List selectedServices = new ArrayList<>(); + private Span netTotalLabel; + private Span vatTotalLabel; + private Span grossTotalLabel; // Date picker fields for appointments private DatePicker pickupDate; @@ -152,19 +166,36 @@ public class AddJobView extends Main { // Available app users for the current user private List availableAppUsers; + // Adressvalidierung + private final Map addressValidationResults = new HashMap<>(); + private RouteCalculationResult routeCalculationResult; + private String lastPickupStreet = ""; + private String lastPickupHouseNumber = ""; + private String lastPickupZip = ""; + private String lastPickupCity = ""; + private String lastDeliveryStreet = ""; + private String lastDeliveryHouseNumber = ""; + private String lastDeliveryZip = ""; + private String lastDeliveryCity = ""; + private TabSheet tabSheet; + public AddJobView(AddJobService addJobService, AddCustomerService addCustomerService, CustomerService customerService, AppUserService appUserService, TaskTemplateService taskTemplateService, - SecurityService securityService) { + SecurityService securityService, ServiceRepository serviceRepository, + AddressValidationService addressValidationService) { this.addJobService = addJobService; this.addCustomerService = addCustomerService; this.customerService = customerService; this.appUserService = appUserService; this.taskTemplateService = taskTemplateService; this.securityService = securityService; + this.serviceRepository = serviceRepository; + this.addressValidationService = addressValidationService; initializeComponents(); setupLayout(); setupValidation(); loadDraftIfExists(); + loadMandatoryServices(); } private void initializeComponents() { @@ -357,20 +388,7 @@ public class AddJobView extends Main { user -> user.getVorname() + " " + user.getNachname() + " (" + user.getEmail() + ")"); appUser.setPlaceholder("App-Nutzer auswählen..."); - // Price field - price = new TextField("Preis"); - price.setPlaceholder("Betrag eingeben"); - price.setRequiredIndicatorVisible(true); - - // Erzwinge Komma als Dezimaltrennzeichen: ersetze Punkt beim Tippen - price.addValueChangeListener(e -> { - String v = e.getValue(); - if (v != null && v.contains(".")) { - String replaced = v.replace('.', ','); - if (!replaced.equals(v)) - price.setValue(replaced); - } - }); + // Services grid will be initialized in createPriceAndSubmitTab() // Date picker fields for appointments pickupDate = new DatePicker("Datum"); pickupDate.setRequiredIndicatorVisible(true); @@ -412,7 +430,7 @@ public class AddJobView extends Main { // Create TabSheet for organizing the form // TabSheet and Tab references for dynamic label updates - TabSheet tabSheet = new TabSheet(); + tabSheet = new TabSheet(); tabSheet.setSizeFull(); // Tab 1: Customer & Addresses @@ -437,7 +455,10 @@ public class AddJobView extends Main { } // Tab 5: Price & Submit - priceTab = tabSheet.add("Preis & Abschluss", createPriceAndSubmitTab()); + priceTab = tabSheet.add("Leistungen und Preis", createPriceAndSubmitTab()); + + // Tab-Wechsel-Listener für Adressvalidierung + tabSheet.addSelectedChangeListener(this::onTabChange); add(tabSheet); @@ -573,24 +594,200 @@ public class AddJobView extends Main { tabContent.setSizeFull(); tabContent.setPadding(true); tabContent.setSpacing(true); - tabContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER); + tabContent.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH); - // Container with fixed width to center content + // Container with full width like other tabs VerticalLayout content = new VerticalLayout(); content.setPadding(false); content.setSpacing(true); - content.setWidth("720px"); + content.setWidthFull(); content.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH); - // Preis (netto) - moved from createTasksAndNotesSection - H3 priceTitle = new H3("Preis (netto)"); - priceTitle.getStyle().set("margin", "0"); - content.add(priceTitle, price); + // Title + H3 servicesTitle = new H3("Leistungen"); + servicesTitle.getStyle().set("margin", "0"); + content.add(servicesTitle); + + // Services Grid + servicesGrid = new Grid<>(); + servicesGrid.setWidthFull(); + servicesGrid.setHeight("250px"); + servicesGrid.setItems(selectedServices); + + servicesGrid.addColumn(Service::getName).setHeader("Leistung").setSortable(true); + servicesGrid.addColumn(service -> { + if (service.getCalculationBasis() != null) { + return switch (service.getCalculationBasis()) { + case DISTANCE -> "Gefahrene Kilometer"; + case TIME -> "Zeit"; + case FLAT_RATE -> "Pauschal"; + }; + } + return ""; + }).setHeader("Berechnung").setSortable(true); + servicesGrid.addColumn(service -> { + if (service.getEffectivePrice() != null) { + return service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €"; + } + return ""; + }).setHeader("Preis").setSortable(true); + servicesGrid.addColumn(service -> { + if (service.getVatRate() != null) { + return service.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + " %"; + } + return ""; + }).setHeader("MwSt").setSortable(true); + servicesGrid.addComponentColumn(service -> { + Button removeButton = new Button(new Icon(VaadinIcon.TRASH)); + removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY, + ButtonVariant.LUMO_SMALL); + removeButton.addClickListener(e -> { + selectedServices.remove(service); + servicesGrid.getDataProvider().refreshAll(); + updatePriceSummary(); + triggerValidation(); + updateTabLabels(); + }); + return removeButton; + }).setHeader("Aktion").setAutoWidth(true).setFlexGrow(0); + + content.add(servicesGrid); + + // Add Service Button + Button addServiceButton = new Button("Leistung hinzufügen", new Icon(VaadinIcon.PLUS)); + addServiceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + addServiceButton.addClickListener(e -> openAddServiceDialog()); + content.add(addServiceButton); + + // Price Summary + VerticalLayout summaryLayout = new VerticalLayout(); + summaryLayout.setPadding(true); + summaryLayout.setSpacing(true); + summaryLayout.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); + summaryLayout.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + summaryLayout.getStyle().set("background-color", "var(--lumo-contrast-5pct)"); + summaryLayout.setWidthFull(); + summaryLayout.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH); + + H3 summaryTitle = new H3("Zusammenfassung"); + summaryTitle.getStyle().set("margin", "0"); + summaryLayout.add(summaryTitle); + + // Net total + HorizontalLayout netRow = new HorizontalLayout(); + netRow.setWidthFull(); + netRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + Span netLabel = new Span("Nettosumme:"); + netTotalLabel = new Span("0,00 €"); + netTotalLabel.getStyle().set("font-weight", "bold"); + netRow.add(netLabel, netTotalLabel); + summaryLayout.add(netRow); + + // VAT total + HorizontalLayout vatRow = new HorizontalLayout(); + vatRow.setWidthFull(); + vatRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + Span vatLabel = new Span("Umsatzsteuer:"); + vatTotalLabel = new Span("0,00 €"); + vatTotalLabel.getStyle().set("font-weight", "bold"); + vatRow.add(vatLabel, vatTotalLabel); + summaryLayout.add(vatRow); + + // Gross total + HorizontalLayout grossRow = new HorizontalLayout(); + grossRow.setWidthFull(); + grossRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + Span grossLabel = new Span("Bruttosumme:"); + grossLabel.getStyle().set("font-size", "var(--lumo-font-size-l)"); + grossTotalLabel = new Span("0,00 €"); + grossTotalLabel.getStyle().set("font-size", "var(--lumo-font-size-l)"); + grossTotalLabel.getStyle().set("font-weight", "bold"); + grossTotalLabel.getStyle().set("color", "var(--lumo-primary-text-color)"); + grossRow.add(grossLabel, grossTotalLabel); + summaryLayout.add(grossRow); + + content.add(summaryLayout); tabContent.add(content); return tabContent; } + private void openAddServiceDialog() { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Leistung auswählen"); + dialog.setWidth("500px"); + + VerticalLayout dialogContent = new VerticalLayout(); + dialogContent.setPadding(true); + dialogContent.setSpacing(true); + + // Load available services for current user + List availableServices = serviceRepository + .findByUserId(securityService.getCurrentDatabaseUser().getId().toString()); + + ComboBox serviceCombo = new ComboBox<>("Leistung"); + serviceCombo.setWidthFull(); + serviceCombo.setItems(availableServices); + serviceCombo.setItemLabelGenerator(service -> { + // Only show price for FLAT_RATE services + if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE + && service.getEffectivePrice() != null) { + return service.getName() + " (" + service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €)"; + } + return service.getName(); + }); + serviceCombo.setPlaceholder("Leistung auswählen..."); + serviceCombo.setRequired(true); + + dialogContent.add(serviceCombo); + + HorizontalLayout buttonLayout = new HorizontalLayout(); + buttonLayout.setWidthFull(); + buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END); + buttonLayout.setSpacing(true); + + Button cancelButton = new Button("Abbrechen", e -> dialog.close()); + cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + Button addButton = new Button("Hinzufügen", e -> { + if (serviceCombo.getValue() != null) { + selectedServices.add(serviceCombo.getValue()); + servicesGrid.getDataProvider().refreshAll(); + updatePriceSummary(); + triggerValidation(); + updateTabLabels(); + dialog.close(); + } + }); + addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + buttonLayout.add(cancelButton, addButton); + dialogContent.add(buttonLayout); + + dialog.add(dialogContent); + dialog.open(); + } + + private void updatePriceSummary() { + BigDecimal netTotal = BigDecimal.ZERO; + BigDecimal vatTotal = BigDecimal.ZERO; + BigDecimal grossTotal = BigDecimal.ZERO; + + for (Service service : selectedServices) { + BigDecimal price = service.getEffectivePrice() != null ? service.getEffectivePrice() : BigDecimal.ZERO; + BigDecimal vatRate = service.getVatRate() != null ? service.getVatRate() : BigDecimal.ZERO; + + netTotal = netTotal.add(price); + BigDecimal vatAmount = price.multiply(vatRate); + vatTotal = vatTotal.add(vatAmount); + grossTotal = grossTotal.add(price.add(vatAmount)); + } + + netTotalLabel.setText(netTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €"); + vatTotalLabel.setText(vatTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €"); + grossTotalLabel.setText(grossTotal.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €"); + } + private VerticalLayout createPickupSection() { VerticalLayout section = new VerticalLayout(); section.setSpacing(true); @@ -833,21 +1030,15 @@ public class AddJobView extends Main { binder.forField(deliveryCity).asRequired("").bind(Job::getDeliveryCity, Job::setDeliveryCity); - // Bind price field: Komma-Zahlen in Punkt-Zahlen umsetzen, dann nach BigDecimal - // konvertieren - binder.forField(price).withNullRepresentation("").asRequired("Preis erforderlich").withConverter((String s) -> { - if (s == null || s.trim().isEmpty()) - return null; - String normalized = s.replace(" ", "").replace(".", "").replace(',', '.'); - try { - return new java.math.BigDecimal(normalized); - } catch (NumberFormatException ex) { - throw new NumberFormatException("Ungültiger Betrag"); - } - }, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString(), "Ungültiger Betrag") - .withValidator(value -> value != null && value.compareTo(java.math.BigDecimal.ZERO) > 0, - "Der Preis muss größer als 0 sein") - .bind(Job::getPrice, Job::setPrice); + // Price is now calculated from selected services - bind to job price for + // storage + binder.forField(new com.vaadin.flow.component.textfield.TextField()).withConverter((String str) -> { + // Calculate total from selected services + BigDecimal total = selectedServices.stream() + .map(svc -> svc.getEffectivePrice() != null ? svc.getEffectivePrice() : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return total; + }, (java.math.BigDecimal bd) -> bd == null ? "" : bd.toString()).bind(Job::getPrice, Job::setPrice); // Bind date picker fields with validation binder.forField(pickupDate).asRequired("") @@ -952,7 +1143,7 @@ public class AddJobView extends Main { // List of all required fields TextField[] requiredTextFields = { pickupFirstName, pickupLastName, pickupStreet, pickupHouseNumber, pickupZip, pickupCity, deliveryFirstName, deliveryLastName, deliveryStreet, deliveryHouseNumber, deliveryZip, - deliveryCity, price }; + deliveryCity }; // List of required date fields DatePicker[] requiredDateFields = { pickupDate, deliveryDate }; @@ -1078,8 +1269,8 @@ public class AddJobView extends Main { private boolean hasAppointmentValidationErrors() { LocalDate today = LocalDate.now(); - return pickupDate.getValue() == null || deliveryDate.getValue() == null - || pickupDate.getValue().isBefore(today) || deliveryDate.getValue().isBefore(today); + return pickupDate.getValue() == null || deliveryDate.getValue() == null || pickupDate.getValue().isBefore(today) + || deliveryDate.getValue().isBefore(today); } private boolean hasCargoValidationErrors() { @@ -1105,7 +1296,8 @@ public class AddJobView extends Main { private boolean hasTasksValidationErrors() { for (BaseTask task : tasksState) { - // Check if any ConfirmationTask has an empty description or buttonText (required fields) + // Check if any ConfirmationTask has an empty description or buttonText + // (required fields) if (task instanceof ConfirmationTask confirmationTask) { String description = task.getDescription(); if (description == null || description.trim().isEmpty()) { @@ -1133,7 +1325,8 @@ public class AddJobView extends Main { } private boolean hasPriceValidationErrors() { - return isFieldEmpty(price); + // Price tab is valid when at least one service is selected + return selectedServices == null || selectedServices.isEmpty(); } private boolean isFieldEmpty(TextField field) { @@ -1153,6 +1346,15 @@ public class AddJobView extends Main { if (remarkArea != null) job.setRemark(remarkArea.getValue()); + // Calculate price from selected services + BigDecimal totalPrice = selectedServices.stream() + .map(s -> s.getEffectivePrice() != null ? s.getEffectivePrice() : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + job.setPrice(totalPrice); + + // Store selected service IDs in job (optional - if Job has serviceIds field) + // job.setServiceIds(selectedServices.stream().map(Service::getId).toList()); + // Validate all required fields using the binder if (binder.writeBeanIfValid(job)) { // Additional validation: If digital processing is enabled, app user must be @@ -1248,6 +1450,31 @@ public class AddJobView extends Main { // Zusammenfassungs-Helfer entfernt (Route übernimmt Darstellung) + /** + * Lädt verpflichtende Leistungen aus dem Leistungskatalog + */ + private void loadMandatoryServices() { + try { + User currentUser = securityService.getCurrentDatabaseUser(); + if (currentUser != null) { + List userServices = serviceRepository.findByUserId(currentUser.getId().toString()); + List mandatoryServices = userServices.stream().filter(Service::isMandatory).toList(); + + if (!mandatoryServices.isEmpty()) { + selectedServices.addAll(mandatoryServices); + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } + updatePriceSummary(); + triggerValidation(); + updateTabLabels(); + } + } + } catch (Exception e) { + log.warn("Fehler beim Laden der verpflichtenden Leistungen: {}", e.getMessage()); + } + } + /** * Lädt einen bestehenden Entwurf, falls vorhanden */ @@ -1591,8 +1818,12 @@ public class AddJobView extends Main { digitalProcessing.setValue(true); appUser.clear(); - // Price field - price.clear(); + // Clear services + selectedServices.clear(); + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } + updatePriceSummary(); // Benutzer-Feedback Notification.show("Alle Felder wurden geleert", 2000, Notification.Position.BOTTOM_CENTER); @@ -2435,4 +2666,374 @@ public class AddJobView extends Main { return TaskType.CONFIRMATION; // fallback } + // ============================================ + // Adressvalidierung + // ============================================ + + /** + * Wird aufgerufen, wenn der Benutzer den Tab wechselt. Prüft, ob vom Tab + * "Auftraggeber & Adressen" gewechselt wird und ob die Adressen geändert + * wurden. + */ + private void onTabChange(com.vaadin.flow.component.tabs.TabSheet.SelectedChangeEvent event) { + com.vaadin.flow.component.tabs.Tab previousTab = event.getPreviousTab(); + com.vaadin.flow.component.tabs.Tab selectedTab = event.getSelectedTab(); + + // Nur prüfen, wenn vom Adress-Tab weg gewechselt wird + if (previousTab != addressesTab) { + return; + } + + // Prüfen, ob Adressen geändert wurden + boolean pickupChanged = hasPickupAddressChanged(); + boolean deliveryChanged = hasDeliveryAddressChanged(); + + if (!pickupChanged && !deliveryChanged) { + // Adressen nicht geändert, nichts zu tun + return; + } + + // Tab-Wechsel vorübergehend verhindern, indem wir zurück zum Adress-Tab + // wechseln + // Der Dialog wird angezeigt und bei Bestätigung wird der Tab gewechselt + event.unregisterListener(); + tabSheet.setSelectedTab(addressesTab); + + // Validierungsdialog anzeigen + showAddressValidationDialog(selectedTab); + } + + /** + * Prüft, ob sich die Abholadresse geändert hat. + */ + private boolean hasPickupAddressChanged() { + String currentStreet = getValueOrEmpty(pickupStreet); + String currentHouseNumber = getValueOrEmpty(pickupHouseNumber); + String currentZip = getValueOrEmpty(pickupZip); + String currentCity = getValueOrEmpty(pickupCity); + + boolean changed = !currentStreet.equals(lastPickupStreet) || !currentHouseNumber.equals(lastPickupHouseNumber) + || !currentZip.equals(lastPickupZip) || !currentCity.equals(lastPickupCity); + + return changed && !currentStreet.isEmpty() && !currentZip.isEmpty() && !currentCity.isEmpty(); + } + + /** + * Prüft, ob sich die Lieferadresse geändert hat. + */ + private boolean hasDeliveryAddressChanged() { + String currentStreet = getValueOrEmpty(deliveryStreet); + String currentHouseNumber = getValueOrEmpty(deliveryHouseNumber); + String currentZip = getValueOrEmpty(deliveryZip); + String currentCity = getValueOrEmpty(deliveryCity); + + boolean changed = !currentStreet.equals(lastDeliveryStreet) + || !currentHouseNumber.equals(lastDeliveryHouseNumber) || !currentZip.equals(lastDeliveryZip) + || !currentCity.equals(lastDeliveryCity); + + return changed && !currentStreet.isEmpty() && !currentZip.isEmpty() && !currentCity.isEmpty(); + } + + private String getValueOrEmpty(TextField field) { + return field.getValue() != null ? field.getValue().trim() : ""; + } + + /** + * Zeigt den Adressvalidierungsdialog an. + */ + private void showAddressValidationDialog(com.vaadin.flow.component.tabs.Tab targetTab) { + final Dialog dialog = new Dialog(); + dialog.setHeaderTitle("Adressen werden überprüft"); + dialog.setWidth("500px"); + dialog.setModal(true); + dialog.setCloseOnOutsideClick(false); + dialog.setCloseOnEsc(false); + + final VerticalLayout content = new VerticalLayout(); + content.setPadding(true); + content.setSpacing(true); + + // Status-Labels für die Validierung + final Span pickupStatusLabel = new Span("Abholadresse wird überprüft..."); + final Span deliveryStatusLabel = new Span("Lieferadresse wird überprüft..."); + + content.add(pickupStatusLabel, deliveryStatusLabel); + + // Layout für die Ergebnisanzeige + final VerticalLayout resultLayout = new VerticalLayout(); + resultLayout.setVisible(false); + resultLayout.setPadding(false); + resultLayout.setSpacing(true); + + final Span pickupResultLabel = new Span(); + final Span deliveryResultLabel = new Span(); + + // Route-Label für die Anzeige der berechneten Strecke + final Span routeResultLabel = new Span(); + routeResultLabel.getStyle().set("font-weight", "bold"); + routeResultLabel.getStyle().set("margin-top", "var(--lumo-space-s)"); + routeResultLabel.setVisible(false); + + resultLayout.add(pickupResultLabel, deliveryResultLabel, routeResultLabel); + content.add(resultLayout); + + // Button-Layout (initial versteckt) + final HorizontalLayout buttonLayout = new HorizontalLayout(); + buttonLayout.setWidthFull(); + buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END); + buttonLayout.setVisible(false); + + final Button cancelButton = new Button("Zurück", e -> { + dialog.close(); + // Im Adress-Tab bleiben + }); + cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + final Button continueButton = new Button("Trotzdem wechseln", e -> { + dialog.close(); + // Zum Ziel-Tab wechseln + tabSheet.setSelectedTab(targetTab); + // Gespeicherte Adressen aktualisieren + saveCurrentAddresses(); + }); + continueButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + buttonLayout.add(cancelButton, continueButton); + content.add(buttonLayout); + + dialog.add(content); + dialog.open(); + + // Asynchrone Validierung durchführen + getUI().ifPresent(ui -> { + // UI-Zugriff für Validierung + ui.access(() -> { + // Abholadresse validieren + final AddressValidationResult[] pickupResultHolder = new AddressValidationResult[1]; + if (hasPickupAddressChanged()) { + pickupResultHolder[0] = addressValidationService.validateAddress("pickup", + getValueOrEmpty(pickupStreet), getValueOrEmpty(pickupHouseNumber), + getValueOrEmpty(pickupZip), getValueOrEmpty(pickupCity)); + addressValidationResults.put("pickup", pickupResultHolder[0]); + } + + // Lieferadresse validieren + final AddressValidationResult[] deliveryResultHolder = new AddressValidationResult[1]; + if (hasDeliveryAddressChanged()) { + deliveryResultHolder[0] = addressValidationService.validateAddress("delivery", + getValueOrEmpty(deliveryStreet), getValueOrEmpty(deliveryHouseNumber), + getValueOrEmpty(deliveryZip), getValueOrEmpty(deliveryCity)); + addressValidationResults.put("delivery", deliveryResultHolder[0]); + } + + // Route berechnen, wenn beide Adressen gültig sind + final RouteCalculationResult[] routeResultHolder = new RouteCalculationResult[1]; + AddressValidationResult pickup = pickupResultHolder[0]; + AddressValidationResult delivery = deliveryResultHolder[0]; + + if ((pickup != null && pickup.isValid()) && (delivery != null && delivery.isValid())) { + routeResultHolder[0] = addressValidationService.calculateRoute(pickup, delivery); + routeCalculationResult = routeResultHolder[0]; + } else if (pickup == null) { + // Bereits validierte Abholadresse verwenden + AddressValidationResult existingPickup = addressValidationResults.get("pickup"); + if (existingPickup != null && existingPickup.isValid() && delivery != null && delivery.isValid()) { + routeResultHolder[0] = addressValidationService.calculateRoute(existingPickup, delivery); + routeCalculationResult = routeResultHolder[0]; + } + } else if (delivery == null) { + // Bereits validierte Lieferadresse verwenden + AddressValidationResult existingDelivery = addressValidationResults.get("delivery"); + if (existingDelivery != null && existingDelivery.isValid() && pickup != null && pickup.isValid()) { + routeResultHolder[0] = addressValidationService.calculateRoute(pickup, existingDelivery); + routeCalculationResult = routeResultHolder[0]; + } + } + + // UI aktualisieren + updateValidationDialogUI(pickup, delivery, pickupStatusLabel, deliveryStatusLabel, pickupResultLabel, + deliveryResultLabel, routeResultLabel, resultLayout, buttonLayout, continueButton, targetTab); + }); + }); + } + + /** + * Aktualisiert die UI des Validierungsdialogs mit den Ergebnissen. + */ + private void updateValidationDialogUI(AddressValidationResult pickupResult, AddressValidationResult deliveryResult, + Span pickupStatusLabel, Span deliveryStatusLabel, Span pickupResultLabel, Span deliveryResultLabel, + Span routeResultLabel, VerticalLayout resultLayout, HorizontalLayout buttonLayout, Button continueButton, + com.vaadin.flow.component.tabs.Tab targetTab) { + + boolean hasInvalidAddress = false; + boolean bothAddressesValid = true; + + // Abholadresse anzeigen + if (pickupResult != null) { + if (pickupResult.isValid()) { + pickupResultLabel.setText("✓ Abholadresse: " + pickupResult.getFormattedAddress()); + pickupResultLabel.getStyle().set("color", "var(--lumo-success-text-color)"); + } else { + pickupResultLabel.setText("⚠ Abholadresse: " + pickupResult.getValidationMessage()); + pickupResultLabel.getStyle().set("color", "var(--lumo-error-text-color)"); + hasInvalidAddress = true; + bothAddressesValid = false; + } + } else { + pickupResultLabel.setVisible(false); + } + + // Lieferadresse anzeigen + if (deliveryResult != null) { + if (deliveryResult.isValid()) { + deliveryResultLabel.setText("✓ Lieferadresse: " + deliveryResult.getFormattedAddress()); + deliveryResultLabel.getStyle().set("color", "var(--lumo-success-text-color)"); + } else { + deliveryResultLabel.setText("⚠ Lieferadresse: " + deliveryResult.getValidationMessage()); + deliveryResultLabel.getStyle().set("color", "var(--lumo-error-text-color)"); + hasInvalidAddress = true; + bothAddressesValid = false; + } + } else { + deliveryResultLabel.setVisible(false); + } + + // Prüfen, ob beide Adressen insgesamt gültig sind (auch aus vorherigen + // Validierungen) + AddressValidationResult existingPickup = addressValidationResults.get("pickup"); + AddressValidationResult existingDelivery = addressValidationResults.get("delivery"); + + if (pickupResult != null && !pickupResult.isValid()) { + bothAddressesValid = false; + } else if (pickupResult == null && (existingPickup == null || !existingPickup.isValid())) { + bothAddressesValid = false; + } + + if (deliveryResult != null && !deliveryResult.isValid()) { + bothAddressesValid = false; + } else if (deliveryResult == null && (existingDelivery == null || !existingDelivery.isValid())) { + bothAddressesValid = false; + } + + // Route anzeigen, wenn beide Adressen gültig sind + if (bothAddressesValid && routeCalculationResult != null && routeCalculationResult.isValid()) { + routeResultLabel.setText("🚛 Route: " + String.format("%.1f km", routeCalculationResult.getDistanceKm()) + + " (Fahrtzeit: " + routeCalculationResult.getFormattedDurationLong() + ")"); + routeResultLabel.getStyle().set("color", "var(--lumo-primary-text-color)"); + routeResultLabel.setVisible(true); + } else { + routeResultLabel.setVisible(false); + } + + // Status-Labels ausblenden, Ergebnisse anzeigen + pickupStatusLabel.setVisible(false); + deliveryStatusLabel.setVisible(false); + resultLayout.setVisible(true); + + // Farbliche Markierung der Adressfelder + updateAddressFieldStyles(pickupResult != null ? pickupResult : existingPickup, + deliveryResult != null ? deliveryResult : existingDelivery); + + // Buttons anzeigen + buttonLayout.setVisible(true); + + // Wenn beide Adressen gültig sind, direkt weiter + if (!hasInvalidAddress) { + continueButton.setText("Weiter"); + } else { + continueButton.setText("Trotzdem wechseln"); + } + } + + /** + * Aktualisiert die Hintergrundfarbe der Adressfelder basierend auf dem + * Validierungsergebnis. Hellgrün für validierte Adressen, hellgelb für nicht + * validierte. + */ + private void updateAddressFieldStyles(AddressValidationResult pickupResult, + AddressValidationResult deliveryResult) { + // Abholadresse - hellgrün (#90EE90) für validiert, hellgelb (#FFFACD) für nicht + // validiert + String pickupColor = (pickupResult != null && pickupResult.isValid()) ? "rgba(144, 238, 144, 0.5)" // Hellgrün + // mit + // Transparenz + : "rgba(255, 250, 205, 0.5)"; // Hellgelb mit Transparenz + + pickupStreet.getStyle().set("--vaadin-input-field-background", pickupColor); + pickupHouseNumber.getStyle().set("--vaadin-input-field-background", pickupColor); + pickupZip.getStyle().set("--vaadin-input-field-background", pickupColor); + pickupCity.getStyle().set("--vaadin-input-field-background", pickupColor); + + // Lieferadresse - hellgrün für validiert, hellgelb für nicht validiert + String deliveryColor = (deliveryResult != null && deliveryResult.isValid()) ? "rgba(144, 238, 144, 0.5)" // Hellgrün + // mit + // Transparenz + : "rgba(255, 250, 205, 0.5)"; // Hellgelb mit Transparenz + + deliveryStreet.getStyle().set("--vaadin-input-field-background", deliveryColor); + deliveryHouseNumber.getStyle().set("--vaadin-input-field-background", deliveryColor); + deliveryZip.getStyle().set("--vaadin-input-field-background", deliveryColor); + deliveryCity.getStyle().set("--vaadin-input-field-background", deliveryColor); + } + + /** + * Speichert die aktuellen Adressen als "zuletzt geprüft". + */ + private void saveCurrentAddresses() { + lastPickupStreet = getValueOrEmpty(pickupStreet); + lastPickupHouseNumber = getValueOrEmpty(pickupHouseNumber); + lastPickupZip = getValueOrEmpty(pickupZip); + lastPickupCity = getValueOrEmpty(pickupCity); + + lastDeliveryStreet = getValueOrEmpty(deliveryStreet); + lastDeliveryHouseNumber = getValueOrEmpty(deliveryHouseNumber); + lastDeliveryZip = getValueOrEmpty(deliveryZip); + lastDeliveryCity = getValueOrEmpty(deliveryCity); + } + + /** + * Gibt das Validierungsergebnis für die Abholadresse zurück. Kann null sein, + * wenn noch keine Validierung durchgeführt wurde. + */ + public AddressValidationResult getPickupAddressValidationResult() { + return addressValidationResults.get("pickup"); + } + + /** + * Gibt das Validierungsergebnis für die Lieferadresse zurück. Kann null sein, + * wenn noch keine Validierung durchgeführt wurde. + */ + public AddressValidationResult getDeliveryAddressValidationResult() { + return addressValidationResults.get("delivery"); + } + + /** + * Gibt alle Validierungsergebnisse zurück. + */ + public Map getAddressValidationResults() { + return new HashMap<>(addressValidationResults); + } + + /** + * Gibt das Ergebnis der Routenberechnung zurück. Enthält die Entfernung in + * Kilometern und die Fahrtzeit, wenn beide Adressen validiert wurden. + * + * @return RouteCalculationResult oder null, wenn keine Berechnung durchgeführt + * wurde + */ + public RouteCalculationResult getRouteCalculationResult() { + return routeCalculationResult; + } + + /** + * Gibt die Entfernung zwischen Abhol- und Lieferadresse in Kilometern zurück. + * + * @return Entfernung in km oder 0.0, wenn keine Berechnung durchgeführt wurde + */ + public double getRouteDistanceKm() { + return routeCalculationResult != null && routeCalculationResult.isValid() + ? routeCalculationResult.getDistanceKm() + : 0.0; + } + } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java new file mode 100644 index 0000000..f8c0060 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -0,0 +1,456 @@ +package de.assecutor.votianlt.pages.view; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H3; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.BeforeEvent; +import com.vaadin.flow.router.HasUrlParameter; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.Service; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.ServiceRepository; +import de.assecutor.votianlt.security.SecurityService; +import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; +import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Autowired; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +@PageTitle("Rechnung erstellen") +@Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) +@RolesAllowed({ "USER" }) +@Slf4j +public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter { + + private final JobRepository jobRepository; + private final ServiceRepository serviceRepository; + private final SecurityService securityService; + + private Job currentJob; + private List gridRows = new ArrayList<>(); + private List allUserServices; + private Grid servicesGrid; + private IntegerField kilometersField; + private IntegerField timeField; + private Div servicesSection; + + /** + * Helper class to represent a row in the services grid + */ + public static class ServiceRow { + private Service service; + + public ServiceRow() { + this.service = null; + } + + public ServiceRow(Service service) { + this.service = service; + } + + public Service getService() { + return service; + } + + public void setService(Service service) { + this.service = service; + } + + public boolean isEmpty() { + return service == null; + } + } + + @Autowired + public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository, + SecurityService securityService) { + this.jobRepository = jobRepository; + this.serviceRepository = serviceRepository; + this.securityService = securityService; + + setSizeFull(); + setPadding(true); + setSpacing(true); + } + + @Override + public void setParameter(BeforeEvent event, String jobIdHex) { + try { + ObjectId jobId = new ObjectId(jobIdHex); + loadJob(jobId); + } catch (Exception e) { + log.error("Fehler beim Parsen der Job-ID: " + jobIdHex, e); + add(new Span("Ungültige Auftrags-ID")); + } + } + + public void loadJob(ObjectId jobId) { + currentJob = jobRepository.findById(jobId).orElse(null); + if (currentJob == null) { + add(new Span("Auftrag nicht gefunden")); + return; + } + + createInvoiceView(); + } + + private void createInvoiceView() { + removeAll(); + + // Title + H2 title = new H2("Rechnung erstellen für Auftrag " + currentJob.getJobNumber()); + add(title); + + // Job Details Section + Div jobDetailsSection = createJobDetailsSection(); + add(jobDetailsSection); + + // Performance Data Section + Div performanceDataSection = createPerformanceDataSection(); + add(performanceDataSection); + + // Services Selection Section + Div servicesSection = createServicesSelectionSection(); + add(servicesSection); + + // Summary Section + Div summarySection = createSummarySection(); + add(summarySection); + + // Create Invoice Button + Button createInvoiceButton = new Button("Rechnung erstellen"); + createInvoiceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + createInvoiceButton.addClickListener(e -> createInvoice()); + createInvoiceButton.getStyle().set("margin-bottom", "15px"); + add(createInvoiceButton); + } + + private Div createJobDetailsSection() { + Div section = new Div(); + section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") + .set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); + + H3 sectionTitle = new H3("Auftragsdetails"); + section.add(sectionTitle); + + // Job information + VerticalLayout jobInfo = new VerticalLayout(); + jobInfo.setSpacing(true); + jobInfo.setWidthFull(); + + jobInfo.add(new HorizontalLayout(new Span("Auftragsnummer:"), new Span(currentJob.getJobNumber()))); + jobInfo.add(new HorizontalLayout(new Span("Kunde:"), + new Span(extractCompanyName(currentJob.getCustomerSelection())))); + jobInfo.add(new HorizontalLayout(new Span("Status:"), new Span(currentJob.getStatus().toString()))); + jobInfo.add(new HorizontalLayout(new Span("Preis:"), new Span(currentJob.getPrice() + " €"))); + + section.add(jobInfo); + return section; + } + + private Div createPerformanceDataSection() { + Div section = new Div(); + section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") + .set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); + + H3 sectionTitle = new H3("Leistungsdaten"); + section.add(sectionTitle); + + VerticalLayout performanceLayout = new VerticalLayout(); + performanceLayout.setSpacing(true); + performanceLayout.setWidthFull(); + + // Kilometers field + HorizontalLayout kilometersLayout = new HorizontalLayout(); + kilometersLayout.setWidthFull(); + Span kilometersLabel = new Span("Gefahrene Kilometer:"); + kilometersLabel.getStyle().set("width", "200px"); + kilometersField = new IntegerField(); + kilometersField.setWidth("150px"); + kilometersField.setMin(0); + kilometersField.setValue(currentJob.getKilometersDriven() != null ? currentJob.getKilometersDriven() : 0); + kilometersField.addValueChangeListener(e -> updateSummarySection()); + kilometersLayout.add(kilometersLabel, kilometersField); + performanceLayout.add(kilometersLayout); + + // Time field (in 15-minute units) + HorizontalLayout timeLayout = new HorizontalLayout(); + timeLayout.setWidthFull(); + Span timeLabel = new Span("Arbeitszeit (15-Minuten-Einheiten):"); + timeLabel.getStyle().set("width", "200px"); + timeField = new IntegerField(); + timeField.setWidth("150px"); + timeField.setMin(0); + timeField.setValue(currentJob.getTimeIn15MinUnits() != null ? currentJob.getTimeIn15MinUnits() : 0); + timeField.addValueChangeListener(e -> updateSummarySection()); + timeLayout.add(timeLabel, timeField); + performanceLayout.add(timeLayout); + + section.add(performanceLayout); + return section; + } + + private Div createServicesSelectionSection() { + servicesSection = new Div(); + servicesSection.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") + .set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); + + H3 sectionTitle = new H3("Leistungen auswählen"); + servicesSection.add(sectionTitle); + + // Load services for current user (only once) + if (allUserServices == null) { + String currentUserId = securityService.getCurrentUserId().toHexString(); + allUserServices = serviceRepository.findByUserId(currentUserId); + } + + // Initialize with 2 empty rows if gridRows is empty + if (gridRows.isEmpty()) { + gridRows.add(new ServiceRow()); + gridRows.add(new ServiceRow()); + } + + // Create grid with editable rows + servicesGrid = new Grid<>(); + servicesGrid.setWidthFull(); + servicesGrid.setAllRowsVisible(true); + + // Service selection column (ComboBox) + servicesGrid.addComponentColumn(row -> { + ComboBox serviceCombo = new ComboBox<>(); + serviceCombo.setItems(allUserServices); + serviceCombo.setItemLabelGenerator(Service::getName); + serviceCombo.setPlaceholder("Leistung auswählen..."); + serviceCombo.setWidthFull(); + serviceCombo.setValue(row.getService()); + + serviceCombo.addValueChangeListener(event -> { + row.setService(event.getValue()); + // Refresh the grid to show updated calculation basis and price + servicesGrid.getDataProvider().refreshItem(row); + updateSummarySection(); + }); + + return serviceCombo; + }).setHeader("Leistung").setAutoWidth(true).setFlexGrow(2); + + // Calculation basis column + servicesGrid.addColumn(row -> { + if (row.getService() != null && row.getService().getCalculationBasis() != null) { + return switch (row.getService().getCalculationBasis()) { + case DISTANCE -> "Gefahrene Kilometer"; + case TIME -> "Zeit"; + case FLAT_RATE -> "Pauschal"; + }; + } + return ""; + }).setHeader("Berechnungsgrundlage").setAutoWidth(true).setFlexGrow(1); + + // Price column + servicesGrid.addColumn(row -> { + if (row.getService() != null) { + BigDecimal price = calculateServicePrice(row.getService()); + if (price != null) { + return price.setScale(2, RoundingMode.HALF_UP) + " €"; + } + } + return ""; + }).setHeader("Preis").setAutoWidth(true).setFlexGrow(1).setKey("price"); + + servicesGrid.setItems(gridRows); + servicesSection.add(servicesGrid); + + // Add button to add new row + Button addButton = new Button("Leistung hinzufügen", e -> { + ServiceRow newRow = new ServiceRow(); + gridRows.add(newRow); + servicesGrid.getDataProvider().refreshAll(); + }); + addButton.getStyle().set("margin-top", "var(--lumo-space-m)"); + servicesSection.add(addButton); + + return servicesSection; + } + + private void refreshServicesGrid() { + if (servicesGrid != null) { + servicesGrid.getDataProvider().refreshAll(); + } + } + + private List getSelectedServices() { + return gridRows.stream().filter(row -> row.getService() != null).map(ServiceRow::getService).toList(); + } + + private Div createSummarySection() { + Div section = new Div(); + section.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") + .set("margin-bottom", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); + + H3 sectionTitle = new H3("Zusammenfassung"); + section.add(sectionTitle); + + // Calculate totals + BigDecimal netAmount = calculateNetAmount(); + BigDecimal vatRate = calculateAverageVatRate(); + BigDecimal vatAmount = netAmount.multiply(vatRate); + BigDecimal totalAmount = netAmount.add(vatAmount); + + VerticalLayout summaryInfo = new VerticalLayout(); + summaryInfo.setSpacing(true); + summaryInfo.setWidthFull(); + + // Show only net sum, VAT sums, and total amount without individual services + summaryInfo.add(new HorizontalLayout(new Span("Nettosumme:"), + new Span(netAmount.setScale(2, RoundingMode.HALF_UP) + " €"))); + summaryInfo + .add(new HorizontalLayout( + new Span("Mehrwertsteuer (" + + vatRate.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%):"), + new Span(vatAmount.setScale(2, RoundingMode.HALF_UP) + " €"))); + summaryInfo.add(new HorizontalLayout(new Span("Gesamtbetrag (brutto):"), + new Span(totalAmount.setScale(2, RoundingMode.HALF_UP) + " €"))); + + section.add(summaryInfo); + return section; + } + + private BigDecimal calculateServicePrice(Service service) { + if (service.getCalculationBasis() == null) { + return null; + } + + if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) { + return service.getPrice(); + } else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE + && service.getPricePerKilometer() != null && kilometersField != null + && kilometersField.getValue() != null) { + BigDecimal kilometers = new BigDecimal(kilometersField.getValue()); + return service.getPricePerKilometer().multiply(kilometers); + } else if (service.getCalculationBasis() == Service.CalculationBasis.TIME + && service.getPricePer15Minutes() != null && timeField != null && timeField.getValue() != null) { + BigDecimal timeUnits = new BigDecimal(timeField.getValue()); + return service.getPricePer15Minutes().multiply(timeUnits); + } + + return null; + } + + private BigDecimal calculateNetAmount() { + BigDecimal total = BigDecimal.ZERO; + + for (Service service : getSelectedServices()) { + if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) { + total = total.add(service.getPrice()); + } else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE + && service.getPricePerKilometer() != null && kilometersField != null + && kilometersField.getValue() != null) { + BigDecimal kilometers = new BigDecimal(kilometersField.getValue()); + BigDecimal serviceTotal = service.getPricePerKilometer().multiply(kilometers); + total = total.add(serviceTotal); + } else if (service.getCalculationBasis() == Service.CalculationBasis.TIME + && service.getPricePer15Minutes() != null && timeField != null && timeField.getValue() != null) { + BigDecimal timeUnits = new BigDecimal(timeField.getValue()); + BigDecimal serviceTotal = service.getPricePer15Minutes().multiply(timeUnits); + total = total.add(serviceTotal); + } + } + + return total; + } + + private BigDecimal calculateAverageVatRate() { + List selectedServicesList = getSelectedServices(); + if (selectedServicesList.isEmpty()) { + return new BigDecimal("0.19"); // Default 19% VAT + } + + BigDecimal totalVat = BigDecimal.ZERO; + int count = 0; + + for (Service service : selectedServicesList) { + if (service.getVatRate() != null) { + totalVat = totalVat.add(service.getVatRate()); + count++; + } + } + + if (count > 0) { + return totalVat.divide(new BigDecimal(count), 4, RoundingMode.HALF_UP); + } + + return new BigDecimal("0.19"); // Default 19% VAT + } + + private void updateSummarySection() { + // Update the job with new values + if (kilometersField != null && kilometersField.getValue() != null) { + currentJob.setKilometersDriven(kilometersField.getValue()); + } + if (timeField != null && timeField.getValue() != null) { + currentJob.setTimeIn15MinUnits(timeField.getValue()); + } + + // Refresh the services grid to update calculated prices + refreshServicesGrid(); + + // Recreate the summary section to update the values + int summarySectionIndex = getComponentCount() - 2; // Summary section is second to last + Div newSummarySection = createSummarySection(); + remove(getComponentAt(summarySectionIndex)); // Remove old summary section + addComponentAtIndex(summarySectionIndex, newSummarySection); // Add new summary section + } + + private String extractCompanyName(String customerSelection) { + if (customerSelection == null || customerSelection.isBlank()) { + return ""; + } + // Format: "Firmenname | Vorname Nachname" - extrahiere nur den Firmennamen + int separatorIndex = customerSelection.indexOf(" | "); + if (separatorIndex > 0) { + return customerSelection.substring(0, separatorIndex).trim(); + } + return customerSelection.trim(); + } + + private void createInvoice() { + if (getSelectedServices().isEmpty()) { + Notification.show("Bitte wählen Sie mindestens eine Leistung aus", 3000, Notification.Position.BOTTOM_END); + return; + } + + // Save the updated job with kilometers and time + jobRepository.save(currentJob); + + // Calculate totals for the invoice + BigDecimal netAmount = calculateNetAmount(); + BigDecimal vatRate = calculateAverageVatRate(); + BigDecimal vatAmount = netAmount.multiply(vatRate); + BigDecimal totalAmount = netAmount.add(vatAmount); + + String message = String.format("Rechnung erstellt! Nettosumme: %s €, MwSt: %s €, Gesamt: %s €", + netAmount.setScale(2, RoundingMode.HALF_UP), vatAmount.setScale(2, RoundingMode.HALF_UP), + totalAmount.setScale(2, RoundingMode.HALF_UP)); + + Notification.show(message, 5000, Notification.Position.BOTTOM_END); + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index c6961fb..edc554d 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -18,6 +18,7 @@ import com.vaadin.flow.component.UI; import com.vaadin.flow.component.textfield.EmailField; import com.vaadin.flow.component.textfield.TextField; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; import java.util.Base64; @@ -32,14 +33,20 @@ import com.vaadin.flow.data.validator.EmailValidator; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.Service; import de.assecutor.votianlt.model.UserInvoiceData; import de.assecutor.votianlt.model.invoices.CustomerInvoiceData; import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem; import de.assecutor.votianlt.pages.service.UserService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService; +import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.InvoiceTemplateService; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.orderedlayout.FlexComponent; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.ClientCallable; import jakarta.annotation.security.RolesAllowed; @@ -68,13 +75,17 @@ public class EditProfileView extends HorizontalLayout { private Checkbox billingEnabled; private VerticalLayout propertiesPanelProfile; + private final ServiceRepository serviceRepository; + private Grid servicesGrid; + public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService, - CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, - SecurityService securityService) { + CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, + SecurityService securityService, ServiceRepository serviceRepository) { this.userInvoiceDataService = userInvoiceDataService; this.customerInvoiceService = customerInvoiceService; this.invoiceTemplateService = invoiceTemplateService; this.currentUser = securityService.getCurrentDatabaseUser(); + this.serviceRepository = serviceRepository; setSizeFull(); setPadding(true); setSpacing(true); @@ -294,7 +305,7 @@ public class EditProfileView extends HorizontalLayout { introTextArea = new TextArea(); termsTextArea = new TextArea(); pdfFrame = new IFrame(); - + // Nur die Checkbox "Rechnungslegung über votianLT" billingEnabled = new Checkbox("Rechnungslegung über votianLT"); billingEnabled.setValue(true); // Standardmäßig aktiviert @@ -311,34 +322,26 @@ public class EditProfileView extends HorizontalLayout { VerticalLayout leftPanel = createTemplatesPanelForProfile(); leftPanel.setWidth("220px"); leftPanel.setHeightFull(); - leftPanel.getStyle() - .set("flex-shrink", "0") - .set("min-width", "220px") - .set("overflow", "auto") + leftPanel.getStyle().set("flex-shrink", "0").set("min-width", "220px").set("overflow", "auto") .set("background-color", "var(--lumo-contrast-5pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("padding", "var(--lumo-space-m)"); + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)"); // Mitte: Canvas Div canvasContainer = new Div(); canvasContainer.setId("invoice-canvas-container-profile"); canvasContainer.setWidth("100%"); canvasContainer.setHeight("100%"); - canvasContainer.getStyle() - .set("background-color", "#e8e8e8") + canvasContainer.getStyle().set("background-color", "#e8e8e8") .set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("overflow", "hidden") + .set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "hidden") .set("position", "relative"); - + VerticalLayout centerPanel = new VerticalLayout(); centerPanel.setWidth("60%"); centerPanel.setHeightFull(); centerPanel.setPadding(false); centerPanel.setSpacing(false); - centerPanel.getStyle() - .set("flex-grow", "1") - .set("min-width", "0"); + centerPanel.getStyle().set("flex-grow", "1").set("min-width", "0"); centerPanel.add(canvasContainer); centerPanel.expand(canvasContainer); @@ -346,74 +349,73 @@ public class EditProfileView extends HorizontalLayout { propertiesPanelProfile = createPropertiesPanelForProfile(); propertiesPanelProfile.setWidth("280px"); propertiesPanelProfile.setHeightFull(); - propertiesPanelProfile.getStyle() - .set("flex-shrink", "0") - .set("min-width", "280px") - .set("overflow", "auto") + propertiesPanelProfile.getStyle().set("flex-shrink", "0").set("min-width", "280px").set("overflow", "auto") .set("background-color", "var(--lumo-contrast-5pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("padding", "var(--lumo-space-m)"); + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)"); mainLayout.add(leftPanel, centerPanel, propertiesPanelProfile); mainLayout.expand(centerPanel); billingTab.add(mainLayout); billingTab.expand(mainLayout); - + // Initialen Zustand setzen (sichtbar da checkbox standardmäßig true) mainLayout.setVisible(true); - + // Action Buttons für den Rechnungsgenerator final HorizontalLayout actionLayout = new HorizontalLayout(); actionLayout.setWidthFull(); actionLayout.setJustifyContentMode(JustifyContentMode.END); actionLayout.setSpacing(true); actionLayout.getStyle().set("margin-top", "var(--lumo-space-s)"); - + Button clearButton = new Button("Leeren", new Icon(VaadinIcon.TRASH)); clearButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); clearButton.addClickListener(e -> { getElement().executeJs("if (window.clearProfileCanvas) { window.clearProfileCanvas(); }"); Notification.show("Canvas wurde geleert", 2000, Notification.Position.BOTTOM_CENTER); }); - + Button previewPdfButton = new Button("Vorschau", new Icon(VaadinIcon.EYE)); previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile()); - + Button saveTemplateButton = new Button("Template speichern", new Icon(VaadinIcon.DOWNLOAD)); saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); saveTemplateButton.addClickListener(e -> { getElement().executeJs( - "if (window.getProfileCanvasData) { return JSON.stringify(window.getProfileCanvasData()); } else { return null; }") - .then(result -> { - if (result == null) { - Notification.show("Fehler: Canvas-Daten konnten nicht gelesen werden", 3000, Notification.Position.BOTTOM_CENTER); - return; - } - try { - String templateData; - if (result instanceof elemental.json.JsonValue) { - elemental.json.JsonValue jsonValue = (elemental.json.JsonValue) result; - templateData = jsonValue.toJson(); - } else { - templateData = result.toString(); + "if (window.getProfileCanvasData) { return JSON.stringify(window.getProfileCanvasData()); } else { return null; }") + .then(result -> { + if (result == null) { + Notification.show("Fehler: Canvas-Daten konnten nicht gelesen werden", 3000, + Notification.Position.BOTTOM_CENTER); + return; } - invoiceTemplateService.saveTemplate(currentUser.getId().toString(), templateData); - Notification.show("Template erfolgreich gespeichert", 3000, Notification.Position.BOTTOM_CENTER); - } catch (Exception ex) { - Notification.show("Fehler beim Speichern: " + ex.getMessage(), 5000, Notification.Position.BOTTOM_CENTER); - } - }); + try { + String templateData; + if (result instanceof elemental.json.JsonValue) { + elemental.json.JsonValue jsonValue = (elemental.json.JsonValue) result; + templateData = jsonValue.toJson(); + } else { + templateData = result.toString(); + } + invoiceTemplateService.saveTemplate(currentUser.getId().toString(), templateData); + Notification.show("Template erfolgreich gespeichert", 3000, + Notification.Position.BOTTOM_CENTER); + } catch (Exception ex) { + Notification.show("Fehler beim Speichern: " + ex.getMessage(), 5000, + Notification.Position.BOTTOM_CENTER); + } + }); }); - + actionLayout.add(clearButton, previewPdfButton, saveTemplateButton); billingTab.add(actionLayout); - + // Initialen Zustand setzen (sichtbar da checkbox standardmäßig true) actionLayout.setVisible(true); - + tabSheet.add("Rechnungserstellung", billingTab); - + // Sichtbarkeit des Invoice Generators an Checkbox binden billingEnabled.addValueChangeListener(e -> { boolean visible = e.getValue(); @@ -423,24 +425,17 @@ public class EditProfileView extends HorizontalLayout { // Bestehende Rechnungsdaten laden (nur für die Checkbox) loadInvoiceData(); - + // Initialize invoice generator when the billing tab is selected // Also register this view instance for JavaScript callbacks tabSheet.addSelectedChangeListener(e -> { if ("Rechnungserstellung".equals(e.getSelectedTab().getLabel())) { - getElement().executeJs( - "window.invoiceGeneratorViewProfile = $0;" + - "setTimeout(function() { " + - " if (window.initProfileInvoiceGenerator) { " + - " window.initProfileInvoiceGenerator(); " + - " console.log('Canvas initialized, now loading template...'); " + - " setTimeout(function() { " + - " $0.$server.onCanvasReady(); " + - " }, 200); " + - " } else { " + - " console.error('initProfileInvoiceGenerator not found'); " + - " } " + - "}, 300);", getElement()); + getElement().executeJs("window.invoiceGeneratorViewProfile = $0;" + "setTimeout(function() { " + + " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); " + + " console.log('Canvas initialized, now loading template...'); " + + " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); " + + " } else { " + " console.error('initProfileInvoiceGenerator not found'); " + " } " + + "}, 300);", getElement()); } }); @@ -507,6 +502,10 @@ public class EditProfileView extends HorizontalLayout { tabSheet.add("Konto", securityTab); + // Leistungskatalog Tab + VerticalLayout servicesTab = createServicesTab(); + tabSheet.add("Leistungskatalog", servicesTab); + // Profil speichern Button (unten rechts) Button saveProfile = new Button("Profiländerungen speichern"); saveProfile.addThemeVariants(ButtonVariant.LUMO_PRIMARY); @@ -789,7 +788,8 @@ public class EditProfileView extends HorizontalLayout { "if (window.getProfileCanvasData) { return window.getProfileCanvasData(); } else { return null; }") .then(result -> { if (result == null) { - Notification.show("Fehler: Canvas-Daten konnten nicht gelesen werden", 3000, Notification.Position.BOTTOM_CENTER); + Notification.show("Fehler: Canvas-Daten konnten nicht gelesen werden", 3000, + Notification.Position.BOTTOM_CENTER); return; } try { @@ -800,10 +800,12 @@ public class EditProfileView extends HorizontalLayout { } else { templateData = result.toString(); } - byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData, currentUser); + byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData, + currentUser); showPdfInDialog(pdfBytes); } catch (Exception ex) { - Notification.show("Fehler beim Generieren der Vorschau: " + ex.getMessage(), 3000, Notification.Position.BOTTOM_CENTER); + Notification.show("Fehler beim Generieren der Vorschau: " + ex.getMessage(), 3000, + Notification.Position.BOTTOM_CENTER); } }); } catch (Exception ex) { @@ -826,33 +828,26 @@ public class EditProfileView extends HorizontalLayout { Div pdfContainer = new Div(); pdfContainer.setWidth("100%"); pdfContainer.setHeight("100%"); - pdfContainer.getStyle() - .set("display", "flex") - .set("flex-direction", "column") - .set("overflow", "hidden"); + pdfContainer.getStyle().set("display", "flex").set("flex-direction", "column").set("overflow", "hidden"); // Use an iframe with data URL for PDF display IFrame pdfFrame = new IFrame(); pdfFrame.setWidth("100%"); pdfFrame.setHeight("100%"); pdfFrame.getElement().setAttribute("src", dataUrl); - pdfFrame.getStyle() - .set("border", "none") - .set("flex-grow", "1"); + pdfFrame.getStyle().set("border", "none").set("flex-grow", "1"); pdfContainer.add(pdfFrame); // Close button Button closeButton = new Button("Schließen", e -> pdfDialog.close()); closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - + // Download button Button downloadButton = new Button("Herunterladen", e -> { - getElement().executeJs( - "const link = document.createElement('a');" + - "link.href = 'data:application/pdf;base64," + base64Pdf + "';" + - "link.download = 'vorschau.pdf';" + - "link.click();"); + getElement() + .executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64," + + base64Pdf + "';" + "link.download = 'vorschau.pdf';" + "link.click();"); }); downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); @@ -870,9 +865,7 @@ public class EditProfileView extends HorizontalLayout { // Bereich 1: Meine Stammdaten (Variablen) Span invoiceHeader = new Span("Meine Stammdaten (Variablen)"); - invoiceHeader.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-m)") + invoiceHeader.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-m)") .set("margin-top", "var(--lumo-space-s)"); // Stammdaten als Variablen - mit tatsächlichen Werten aus currentUser @@ -882,41 +875,43 @@ public class EditProfileView extends HorizontalLayout { String city = safe(currentUser.getZip()) + " " + safe(currentUser.getCity()); String email = safe(currentUser.getEmail()); String phone = safe(currentUser.getPhone()); - - Div senderCompany = createVariableTemplate("Firma", VaadinIcon.OFFICE, "masterdata.company_name", + + Div senderCompany = createVariableTemplate("Firma", VaadinIcon.OFFICE, "masterdata.company_name", company.isEmpty() ? "Ihre Firma" : company); - Div senderName = createVariableTemplate("Name", VaadinIcon.USER, "masterdata.contact_name", + Div senderName = createVariableTemplate("Name", VaadinIcon.USER, "masterdata.contact_name", fullName.trim().isEmpty() ? "Ihr Name" : fullName.trim()); - Div senderAddress = createVariableTemplate("Straße", VaadinIcon.MAP_MARKER, "masterdata.street", + Div senderAddress = createVariableTemplate("Straße", VaadinIcon.MAP_MARKER, "masterdata.street", street.trim().isEmpty() ? "Ihre Straße" : street.trim()); - Div senderCity = createVariableTemplate("Ort", VaadinIcon.BUILDING, "masterdata.city", + Div senderCity = createVariableTemplate("Ort", VaadinIcon.BUILDING, "masterdata.city", city.trim().isEmpty() ? "PLZ Ort" : city.trim()); - Div senderEmail = createVariableTemplate("E-Mail", VaadinIcon.ENVELOPE, "masterdata.email", + Div senderEmail = createVariableTemplate("E-Mail", VaadinIcon.ENVELOPE, "masterdata.email", email.isEmpty() ? "ihre@email.de" : email); - Div senderPhone = createVariableTemplate("Telefon", VaadinIcon.PHONE, "masterdata.phone", + Div senderPhone = createVariableTemplate("Telefon", VaadinIcon.PHONE, "masterdata.phone", phone.isEmpty() ? "Ihre Telefonnummer" : phone); // Bereich 2: Kundendaten (Variablen) Span customerHeader = new Span("Kundendaten (Variablen)"); - customerHeader.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-m)") + customerHeader.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-m)") .set("margin-top", "var(--lumo-space-m)"); // Kundendaten als Variablen (grün hinterlegt) - Div customerCompany = createCustomerVariableTemplate("Kunde Firma", VaadinIcon.OFFICE, "customer.company_name", "Kundenfirma GmbH"); - Div customerName = createCustomerVariableTemplate("Kunde Name", VaadinIcon.USER, "customer.contact_name", "Erika Mustermann"); - Div customerAddress = createCustomerVariableTemplate("Kunde Straße", VaadinIcon.MAP_MARKER, "customer.street", "Kundenstraße 456"); - Div customerCity = createCustomerVariableTemplate("Kunde Ort", VaadinIcon.BUILDING, "customer.city", "54321 Kundenstadt"); - Div customerEmail = createCustomerVariableTemplate("Kunde E-Mail", VaadinIcon.ENVELOPE, "customer.email", "kunde@beispiel.de"); - Div customerPhone = createCustomerVariableTemplate("Kunde Telefon", VaadinIcon.PHONE, "customer.phone", "0987 654321"); + Div customerCompany = createCustomerVariableTemplate("Kunde Firma", VaadinIcon.OFFICE, "customer.company_name", + "Kundenfirma GmbH"); + Div customerName = createCustomerVariableTemplate("Kunde Name", VaadinIcon.USER, "customer.contact_name", + "Erika Mustermann"); + Div customerAddress = createCustomerVariableTemplate("Kunde Straße", VaadinIcon.MAP_MARKER, "customer.street", + "Kundenstraße 456"); + Div customerCity = createCustomerVariableTemplate("Kunde Ort", VaadinIcon.BUILDING, "customer.city", + "54321 Kundenstadt"); + Div customerEmail = createCustomerVariableTemplate("Kunde E-Mail", VaadinIcon.ENVELOPE, "customer.email", + "kunde@beispiel.de"); + Div customerPhone = createCustomerVariableTemplate("Kunde Telefon", VaadinIcon.PHONE, "customer.phone", + "0987 654321"); // Bereich 2: Freie Elemente Span freeHeader = new Span("Freie Elemente"); - freeHeader.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-m)") - .set("margin-top", "var(--lumo-space-m)"); + freeHeader.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-m)").set("margin-top", + "var(--lumo-space-m)"); // Draggable Templates Div textBlock = createDraggableTemplate("Textfeld", VaadinIcon.TEXT_LABEL, "text"); @@ -928,14 +923,10 @@ public class EditProfileView extends HorizontalLayout { Div lineBlock = createDraggableTemplate("Linie", VaadinIcon.LINE_V, "line"); Div imageBlock = createDraggableTemplate("Bild", VaadinIcon.PICTURE, "image"); - panel.add( - invoiceHeader, - senderCompany, senderName, senderAddress, senderCity, senderEmail, senderPhone, - customerHeader, - customerCompany, customerName, customerAddress, customerCity, customerEmail, customerPhone, - freeHeader, - textBlock, headerBlock, dateBlock, customerBlock, companyBlock, amountBlock, lineBlock, imageBlock - ); + panel.add(invoiceHeader, senderCompany, senderName, senderAddress, senderCity, senderEmail, senderPhone, + customerHeader, customerCompany, customerName, customerAddress, customerCity, customerEmail, + customerPhone, freeHeader, textBlock, headerBlock, dateBlock, customerBlock, companyBlock, amountBlock, + lineBlock, imageBlock); return panel; } @@ -943,19 +934,11 @@ public class EditProfileView extends HorizontalLayout { private Div createVariableTemplate(String label, VaadinIcon icon, String variable, String defaultText) { Div template = new Div(); template.setText(label); - template.getStyle() - .set("padding", "var(--lumo-space-s)") - .set("margin", "var(--lumo-space-xs) 0") - .set("background-color", "rgba(25, 118, 210, 0.1)") - .set("border", "1px solid rgba(25, 118, 210, 0.3)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("cursor", "grab") - .set("display", "flex") - .set("align-items", "center") - .set("gap", "var(--lumo-space-s)") - .set("user-select", "none") - .set("font-size", "var(--lumo-font-size-s)") - .set("color", "#1976d2"); + template.getStyle().set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0") + .set("background-color", "rgba(25, 118, 210, 0.1)").set("border", "1px solid rgba(25, 118, 210, 0.3)") + .set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "grab").set("display", "flex") + .set("align-items", "center").set("gap", "var(--lumo-space-s)").set("user-select", "none") + .set("font-size", "var(--lumo-font-size-s)").set("color", "#1976d2"); Icon templateIcon = icon.create(); templateIcon.setSize("var(--lumo-icon-size-s)"); @@ -968,19 +951,16 @@ public class EditProfileView extends HorizontalLayout { template.getElement().setAttribute("data-static-text", defaultText); template.getElement().setAttribute("data-is-customer", "false"); - template.getElement().executeJs( - "this.addEventListener('dragstart', function(e) {" + - " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + - " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + - " e.dataTransfer.setData('variable', this.getAttribute('data-variable'));" + - " e.dataTransfer.setData('static-text', this.getAttribute('data-static-text'));" + - " e.dataTransfer.setData('is-static', 'true');" + - " e.dataTransfer.setData('is-customer', this.getAttribute('data-is-customer'));" + - " this.style.opacity = '0.5';" + - "});" + - "this.addEventListener('dragend', function(e) {" + - " this.style.opacity = '1';" + - "});"); + template.getElement() + .executeJs("this.addEventListener('dragstart', function(e) {" + + " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + + " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + + " e.dataTransfer.setData('variable', this.getAttribute('data-variable'));" + + " e.dataTransfer.setData('static-text', this.getAttribute('data-static-text'));" + + " e.dataTransfer.setData('is-static', 'true');" + + " e.dataTransfer.setData('is-customer', this.getAttribute('data-is-customer'));" + + " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {" + + " this.style.opacity = '1';" + "});"); return template; } @@ -988,19 +968,12 @@ public class EditProfileView extends HorizontalLayout { private Div createCustomerVariableTemplate(String label, VaadinIcon icon, String variable, String defaultText) { Div template = new Div(); template.setText(label); - template.getStyle() - .set("padding", "var(--lumo-space-s)") - .set("margin", "var(--lumo-space-xs) 0") - .set("background-color", "rgba(46, 204, 113, 0.15)") // Friendly green - .set("border", "1px solid rgba(46, 204, 113, 0.4)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("cursor", "grab") - .set("display", "flex") - .set("align-items", "center") - .set("gap", "var(--lumo-space-s)") - .set("user-select", "none") - .set("font-size", "var(--lumo-font-size-s)") - .set("color", "#27ae60"); // Dark green text + template.getStyle().set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0") + .set("background-color", "rgba(46, 204, 113, 0.15)") // Friendly green + .set("border", "1px solid rgba(46, 204, 113, 0.4)").set("border-radius", "var(--lumo-border-radius-m)") + .set("cursor", "grab").set("display", "flex").set("align-items", "center") + .set("gap", "var(--lumo-space-s)").set("user-select", "none") + .set("font-size", "var(--lumo-font-size-s)").set("color", "#27ae60"); // Dark green text Icon templateIcon = icon.create(); templateIcon.setSize("var(--lumo-icon-size-s)"); @@ -1013,19 +986,16 @@ public class EditProfileView extends HorizontalLayout { template.getElement().setAttribute("data-static-text", defaultText); template.getElement().setAttribute("data-is-customer", "true"); - template.getElement().executeJs( - "this.addEventListener('dragstart', function(e) {" + - " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + - " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + - " e.dataTransfer.setData('variable', this.getAttribute('data-variable'));" + - " e.dataTransfer.setData('static-text', this.getAttribute('data-static-text'));" + - " e.dataTransfer.setData('is-static', 'true');" + - " e.dataTransfer.setData('is-customer', this.getAttribute('data-is-customer'));" + - " this.style.opacity = '0.5';" + - "});" + - "this.addEventListener('dragend', function(e) {" + - " this.style.opacity = '1';" + - "});"); + template.getElement() + .executeJs("this.addEventListener('dragstart', function(e) {" + + " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + + " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + + " e.dataTransfer.setData('variable', this.getAttribute('data-variable'));" + + " e.dataTransfer.setData('static-text', this.getAttribute('data-static-text'));" + + " e.dataTransfer.setData('is-static', 'true');" + + " e.dataTransfer.setData('is-customer', this.getAttribute('data-is-customer'));" + + " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {" + + " this.style.opacity = '1';" + "});"); return template; } @@ -1033,17 +1003,10 @@ public class EditProfileView extends HorizontalLayout { private Div createDraggableTemplate(String label, VaadinIcon icon, String type) { Div template = new Div(); template.setText(label); - template.getStyle() - .set("padding", "var(--lumo-space-s)") - .set("margin", "var(--lumo-space-xs) 0") - .set("background-color", "var(--lumo-base-color)") - .set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("cursor", "grab") - .set("display", "flex") - .set("align-items", "center") - .set("gap", "var(--lumo-space-s)") - .set("user-select", "none"); + template.getStyle().set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0") + .set("background-color", "var(--lumo-base-color)").set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "grab").set("display", "flex") + .set("align-items", "center").set("gap", "var(--lumo-space-s)").set("user-select", "none"); Icon templateIcon = icon.create(); templateIcon.setSize("var(--lumo-icon-size-s)"); @@ -1053,16 +1016,12 @@ public class EditProfileView extends HorizontalLayout { template.getElement().setAttribute("data-template-type", type); template.getElement().setAttribute("data-template-label", label); - template.getElement().executeJs( - "this.addEventListener('dragstart', function(e) {" + - " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + - " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + - " e.dataTransfer.setData('is-static', 'false');" + - " this.style.opacity = '0.5';" + - "});" + - "this.addEventListener('dragend', function(e) {" + - " this.style.opacity = '1';" + - "});"); + template.getElement() + .executeJs("this.addEventListener('dragstart', function(e) {" + + " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + + " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + + " e.dataTransfer.setData('is-static', 'false');" + " this.style.opacity = '0.5';" + "});" + + "this.addEventListener('dragend', function(e) {" + " this.style.opacity = '1';" + "});"); return template; } @@ -1074,15 +1033,12 @@ public class EditProfileView extends HorizontalLayout { panel.setHeightFull(); Span header = new Span("Eigenschaften"); - header.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-l)"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)"); Div infoText = new Div(); infoText.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten."); - infoText.getStyle() - .set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)"); + infoText.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", + "var(--lumo-font-size-s)"); panel.add(header, infoText); @@ -1096,9 +1052,7 @@ public class EditProfileView extends HorizontalLayout { propertiesPanelProfile.removeAll(); Span header = new Span("Eigenschaften"); - header.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-l)"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)"); // Element Typ Anzeige String typeDisplay = "Typ: " + elementType; @@ -1109,20 +1063,20 @@ public class EditProfileView extends HorizontalLayout { } Span typeLabel = new Span(typeDisplay); typeLabel.getStyle().set("font-size", "var(--lumo-font-size-s)"); - + propertiesPanelProfile.add(header, typeLabel); - + // Variable anzeigen wenn vorhanden if (variable != null && !variable.isEmpty()) { TextField variableField = new TextField("Variable"); variableField.setValue(variable); variableField.setReadOnly(true); variableField.setWidthFull(); - + // Beschreibung basierend auf der Variable String description = getVariableDescription(variable); variableField.setHelperText(description); - + propertiesPanelProfile.add(variableField); } @@ -1134,7 +1088,7 @@ public class EditProfileView extends HorizontalLayout { upload.setMaxFileSize(5 * 1024 * 1024); // 5 MB upload.setDropLabel(new Span("Bild hierher ziehen oder klicken")); upload.setWidthFull(); - + upload.addSucceededListener(event -> { try { // Bild als Base64 kodieren @@ -1142,22 +1096,23 @@ public class EditProfileView extends HorizontalLayout { String base64 = java.util.Base64.getEncoder().encodeToString(bytes); String mimeType = event.getMIMEType(); String dataUrl = "data:" + mimeType + ";base64," + base64; - + // An JavaScript übergeben - getElement().executeJs( - "if (window.updateProfileElementImage) { window.updateProfileElementImage('" - + elementId + "', $0); }", - dataUrl); + getElement() + .executeJs("if (window.updateProfileElementImage) { window.updateProfileElementImage('" + + elementId + "', $0); }", dataUrl); Notification.show("Bild erfolgreich hochgeladen", 3000, Notification.Position.BOTTOM_CENTER); } catch (Exception ex) { - Notification.show("Fehler beim Hochladen: " + ex.getMessage(), 3000, Notification.Position.BOTTOM_CENTER); + Notification.show("Fehler beim Hochladen: " + ex.getMessage(), 3000, + Notification.Position.BOTTOM_CENTER); } }); - + upload.addFileRejectedListener(event -> { - Notification.show("Datei abgelehnt: " + event.getErrorMessage(), 3000, Notification.Position.BOTTOM_CENTER); + Notification.show("Datei abgelehnt: " + event.getErrorMessage(), 3000, + Notification.Position.BOTTOM_CENTER); }); - + propertiesPanelProfile.add(upload); } @@ -1172,10 +1127,9 @@ public class EditProfileView extends HorizontalLayout { textField.setHelperText("Text kommt aus Ihren Stammdaten"); } else { textField.addValueChangeListener(e -> { - getElement().executeJs( - "if (window.updateProfileElementText) { window.updateProfileElementText('" + elementId - + "', $0); }", - e.getValue()); + getElement() + .executeJs("if (window.updateProfileElementText) { window.updateProfileElementText('" + + elementId + "', $0); }", e.getValue()); }); } propertiesPanelProfile.add(textField); @@ -1189,8 +1143,8 @@ public class EditProfileView extends HorizontalLayout { try { double newX = Double.parseDouble(e.getValue()); getElement().executeJs( - "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" + elementId - + "', $0, null); }", + "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" + + elementId + "', $0, null); }", newX); } catch (NumberFormatException ignored) { } @@ -1205,8 +1159,8 @@ public class EditProfileView extends HorizontalLayout { try { double newY = Double.parseDouble(e.getValue()); getElement().executeJs( - "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" + elementId - + "', null, $0); }", + "if (window.updateProfileElementPosition) { window.updateProfileElementPosition('" + + elementId + "', null, $0); }", newY); } catch (NumberFormatException ignored) { } @@ -1229,54 +1183,44 @@ public class EditProfileView extends HorizontalLayout { } }); propertiesPanelProfile.add(fontSizeField); - + // Farbe Div colorContainer = new Div(); - colorContainer.getStyle() - .set("display", "flex") - .set("align-items", "center") - .set("gap", "var(--lumo-space-s)") - .set("margin-top", "var(--lumo-space-s)"); - + colorContainer.getStyle().set("display", "flex").set("align-items", "center") + .set("gap", "var(--lumo-space-s)").set("margin-top", "var(--lumo-space-s)"); + Span colorLabel = new Span("Farbe"); colorLabel.getStyle().set("font-size", "var(--lumo-font-size-s)"); - + Input colorPicker = new Input(); colorPicker.setType("color"); colorPicker.setValue(color != null ? color : "#333333"); - colorPicker.getStyle() - .set("width", "50px") - .set("height", "32px") - .set("padding", "0") + colorPicker.getStyle().set("width", "50px").set("height", "32px").set("padding", "0") .set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("cursor", "pointer"); - + .set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "pointer"); + TextField colorHexField = new TextField(); colorHexField.setValue(color != null ? color : "#333333"); colorHexField.setWidth("100px"); colorHexField.getStyle().set("margin", "0"); - + colorPicker.addValueChangeListener(e -> { String newColor = e.getValue(); colorHexField.setValue(newColor); - getElement().executeJs( - "if (window.updateProfileElementColor) { window.updateProfileElementColor('" - + elementId + "', $0); }", - newColor); + getElement().executeJs("if (window.updateProfileElementColor) { window.updateProfileElementColor('" + + elementId + "', $0); }", newColor); }); - + colorHexField.addValueChangeListener(e -> { String newColor = e.getValue(); if (newColor.matches("^#[0-9A-Fa-f]{6}$")) { colorPicker.setValue(newColor); - getElement().executeJs( - "if (window.updateProfileElementColor) { window.updateProfileElementColor('" - + elementId + "', $0); }", - newColor); + getElement() + .executeJs("if (window.updateProfileElementColor) { window.updateProfileElementColor('" + + elementId + "', $0); }", newColor); } }); - + colorContainer.add(colorLabel, colorPicker, colorHexField); propertiesPanelProfile.add(colorContainer); } @@ -1287,8 +1231,7 @@ public class EditProfileView extends HorizontalLayout { deleteButton.setWidthFull(); deleteButton.addClickListener(e -> { getElement().executeJs( - "if (window.deleteProfileElement) { window.deleteProfileElement('" + elementId - + "'); }"); + "if (window.deleteProfileElement) { window.deleteProfileElement('" + elementId + "'); }"); resetPropertiesPanel(); }); propertiesPanelProfile.add(deleteButton); @@ -1298,20 +1241,18 @@ public class EditProfileView extends HorizontalLayout { @ClientCallable public void resetPropertiesPanel() { getUI().ifPresent(ui -> ui.access(() -> { - if (propertiesPanelProfile == null) return; - + if (propertiesPanelProfile == null) + return; + propertiesPanelProfile.removeAll(); Span header = new Span("Eigenschaften"); - header.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-l)"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)"); Div infoText = new Div(); infoText.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten."); - infoText.getStyle() - .set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)"); + infoText.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", + "var(--lumo-font-size-s)"); propertiesPanelProfile.add(header, infoText); })); @@ -1331,18 +1272,16 @@ public class EditProfileView extends HorizontalLayout { */ private void loadInvoiceTemplate() { try { - java.util.Optional optionalTemplate = - invoiceTemplateService.getTemplateByUserId(currentUser.getId().toString()); + java.util.Optional optionalTemplate = invoiceTemplateService + .getTemplateByUserId(currentUser.getId().toString()); if (optionalTemplate.isPresent()) { String templateData = optionalTemplate.get().getTemplateData(); if (templateData != null && !templateData.isEmpty()) { - System.out.println("Loading template data: " + templateData.substring(0, Math.min(100, templateData.length())) + "..."); + System.out.println("Loading template data: " + + templateData.substring(0, Math.min(100, templateData.length())) + "..."); // Escape the JSON string for JavaScript - String escapedJson = templateData - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\n", "\\n") - .replace("\r", "\\r"); + String escapedJson = templateData.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n") + .replace("\r", "\\r"); // Build masterdata values JSON String company = safe(currentUser.getCompany()); String fullName = safe(currentUser.getFirstname()) + " " + safe(currentUser.getName()); @@ -1350,25 +1289,18 @@ public class EditProfileView extends HorizontalLayout { String city = safe(currentUser.getZip()) + " " + safe(currentUser.getCity()); String email = safe(currentUser.getEmail()); String phone = safe(currentUser.getPhone()); - String masterdataJson = "{" + - "'masterdata.company_name': '" + company.replace("'", "\\'") + "'," + - "'masterdata.contact_name': '" + fullName.replace("'", "\\'") + "'," + - "'masterdata.street': '" + street.replace("'", "\\'") + "'," + - "'masterdata.city': '" + city.replace("'", "\\'") + "'," + - "'masterdata.email': '" + email.replace("'", "\\'") + "'," + - "'masterdata.phone': '" + phone.replace("'", "\\'") + "'" + - "}"; - getElement().executeJs( - "setTimeout(function() { " + - " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { " + - " console.log('Loading template into canvas...'); " + - " window.masterdataValues = " + masterdataJson + "; " + - " var templateData = JSON.parse('" + escapedJson + "'); " + - " window.loadProfileTemplate(templateData); " + - " } else { " + - " console.error('loadProfileTemplate or canvas not available'); " + - " } " + - "}, 300);"); + String masterdataJson = "{" + "'masterdata.company_name': '" + company.replace("'", "\\'") + "'," + + "'masterdata.contact_name': '" + fullName.replace("'", "\\'") + "'," + + "'masterdata.street': '" + street.replace("'", "\\'") + "'," + "'masterdata.city': '" + + city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'") + + "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "'" + "}"; + getElement().executeJs("setTimeout(function() { " + + " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { " + + " console.log('Loading template into canvas...'); " + " window.masterdataValues = " + + masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); " + + " window.loadProfileTemplate(templateData); " + " } else { " + + " console.error('loadProfileTemplate or canvas not available'); " + " } " + + "}, 300);"); } } else { System.out.println("No template found for user: " + currentUser.getId()); @@ -1384,20 +1316,430 @@ public class EditProfileView extends HorizontalLayout { */ private String getVariableDescription(String variable) { return switch (variable) { - case "masterdata.company_name" -> "Name Ihrer Firma"; - case "masterdata.contact_name" -> "Name des Ansprechpartners"; - case "masterdata.street" -> "Straße und Hausnummer"; - case "masterdata.city" -> "PLZ und Ort"; - case "masterdata.email" -> "E-Mail-Adresse"; - case "masterdata.phone" -> "Telefonnummer"; - case "customer.company_name" -> "Firmenname des Kunden"; - case "customer.contact_name" -> "Name des Kundenansprechpartners"; - case "customer.street" -> "Straße des Kunden"; - case "customer.city" -> "PLZ und Ort des Kunden"; - case "customer.email" -> "E-Mail des Kunden"; - case "customer.phone" -> "Telefon des Kunden"; - default -> "Variable: " + variable; + case "masterdata.company_name" -> "Name Ihrer Firma"; + case "masterdata.contact_name" -> "Name des Ansprechpartners"; + case "masterdata.street" -> "Straße und Hausnummer"; + case "masterdata.city" -> "PLZ und Ort"; + case "masterdata.email" -> "E-Mail-Adresse"; + case "masterdata.phone" -> "Telefonnummer"; + case "customer.company_name" -> "Firmenname des Kunden"; + case "customer.contact_name" -> "Name des Kundenansprechpartners"; + case "customer.street" -> "Straße des Kunden"; + case "customer.city" -> "PLZ und Ort des Kunden"; + case "customer.email" -> "E-Mail des Kunden"; + case "customer.phone" -> "Telefon des Kunden"; + default -> "Variable: " + variable; }; } + /** + * Create the Leistungskatalog tab with services grid and management + * functionality + */ + private VerticalLayout createServicesTab() { + VerticalLayout servicesTab = new VerticalLayout(); + servicesTab.setWidthFull(); + servicesTab.setHeightFull(); + servicesTab.setPadding(false); + servicesTab.setSpacing(true); + + // Header + Span header = new Span("Leistungskatalog"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)").set("margin-bottom", + "var(--lumo-space-m)"); + + // Description + Span description = new Span("Verwalten Sie hier Ihre Leistungen, die Sie Ihren Kunden anbieten."); + description.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", + "var(--lumo-font-size-s)"); + + servicesTab.add(header, description); + + // Grid for displaying services + servicesGrid = new Grid<>(); + servicesGrid.setWidthFull(); + servicesGrid.setHeight("300px"); + + // Configure grid columns + servicesGrid.addColumn(Service::getName).setHeader("Name").setSortable(true); + servicesGrid.addColumn(service -> { + if (service.getCalculationBasis() != null) { + return switch (service.getCalculationBasis()) { + case DISTANCE -> "Gefahrene Kilometer"; + case TIME -> "Zeit"; + case FLAT_RATE -> "Pauschal"; + }; + } + return ""; + }).setHeader("Berechnungsgrundlage").setSortable(true); + + servicesGrid.addColumn(service -> { + if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) { + return service.getPrice().setScale(2, RoundingMode.HALF_UP) + " €"; + } + return "Wird berechnet"; + }).setHeader("Preis").setSortable(true); + + servicesGrid.addColumn(service -> { + if (service.getVatRate() != null) { + return service.getVatRate().multiply(new BigDecimal("100")) + " %"; + } + return ""; + }).setHeader("Mehrwertsteuersatz").setSortable(true); + + servicesGrid.addColumn(service -> service.isMandatory() ? "Ja" : "Nein").setHeader("Verpflichtend") + .setSortable(true); + + // Actions column with edit and delete buttons + servicesGrid.addComponentColumn(service -> { + HorizontalLayout actionsLayout = new HorizontalLayout(); + actionsLayout.setSpacing(false); + actionsLayout.setPadding(false); + actionsLayout.setAlignItems(FlexComponent.Alignment.CENTER); + + Button editButton = new Button(new Icon(VaadinIcon.EDIT)); + editButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + editButton.addClickListener(e -> openServiceDialog(service)); + + Button deleteButton = new Button(new Icon(VaadinIcon.TRASH)); + deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY, + ButtonVariant.LUMO_SMALL); + deleteButton.addClickListener(e -> deleteService(service)); + + actionsLayout.add(editButton, deleteButton); + return actionsLayout; + }).setHeader("Aktionen").setFlexGrow(0).setWidth("120px"); + + servicesTab.add(servicesGrid); + + // Add service button + Button addServiceButton = new Button("Neue Leistung hinzufügen", new Icon(VaadinIcon.PLUS)); + addServiceButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + addServiceButton.addClickListener(e -> openServiceDialog(null)); + + servicesTab.add(addServiceButton); + + // Load services for current user + loadServices(servicesGrid); + + return servicesTab; + } + + /** + * Load services for the current user and populate the grid + */ + private void loadServices(Grid servicesGrid) { + try { + List userServices = serviceRepository.findByUserId(currentUser.getId().toString()); + servicesGrid.setItems(userServices); + } catch (Exception e) { + Notification.show("Fehler beim Laden der Leistungen: " + e.getMessage(), 3000, + Notification.Position.BOTTOM_CENTER); + } + } + + /** + * Open dialog for adding/editing a service + */ + private void openServiceDialog(Service service) { + Dialog dialog = new Dialog(); + dialog.setHeaderTitle(service == null ? "Neue Leistung erstellen" : "Leistung bearbeiten"); + dialog.setWidth("500px"); + + // Form layout + VerticalLayout formLayout = new VerticalLayout(); + formLayout.setPadding(true); + formLayout.setSpacing(true); + formLayout.setWidthFull(); + + // Name field + TextField nameField = new TextField("Name"); + nameField.setWidthFull(); + nameField.setRequired(true); + nameField.setRequiredIndicatorVisible(true); + + // Calculation basis combo box + ComboBox calculationBasisCombo = new ComboBox<>("Berechnungsgrundlage"); + calculationBasisCombo.setWidthFull(); + calculationBasisCombo.setItems(Service.CalculationBasis.values()); + calculationBasisCombo.setItemLabelGenerator(basis -> { + return switch (basis) { + case DISTANCE -> "Gefahrene Kilometer"; + case TIME -> "Zeit"; + case FLAT_RATE -> "Pauschal"; + }; + }); + calculationBasisCombo.setRequired(true); + calculationBasisCombo.setRequiredIndicatorVisible(true); + + // VAT rate field + NumberField vatRateField = new NumberField("Mehrwertsteuersatz (%)"); + vatRateField.setWidthFull(); + vatRateField.setMin(0); + vatRateField.setMax(100); + vatRateField.setStep(0.1); + vatRateField.setValue(19.0); // Default 19% + vatRateField.setRequired(true); + vatRateField.setRequiredIndicatorVisible(true); + + // Mandatory checkbox + Checkbox mandatoryCheckbox = new Checkbox("Verpflichtend"); + mandatoryCheckbox.setValue(false); + + // Set values if editing existing service + if (service != null) { + nameField.setValue(service.getName()); + calculationBasisCombo.setValue(service.getCalculationBasis()); + if (service.getVatRate() != null) { + vatRateField.setValue(service.getVatRate().multiply(new BigDecimal("100")).doubleValue()); + } + mandatoryCheckbox.setValue(service.isMandatory()); + } + + // Price fields for different calculation bases + NumberField flatRatePriceField = new NumberField("Pauschalpreis (€)"); + flatRatePriceField.setWidthFull(); + flatRatePriceField.setMin(0); + flatRatePriceField.setStep(0.01); + flatRatePriceField.setValue(0.0); // Default 0.00 + flatRatePriceField.setRequired(true); + flatRatePriceField.setRequiredIndicatorVisible(true); + + NumberField distancePriceField = new NumberField("Preis pro Kilometer (€)"); + distancePriceField.setWidthFull(); + distancePriceField.setMin(0); + distancePriceField.setStep(0.01); + distancePriceField.setValue(0.0); // Default 0.00 + distancePriceField.setRequired(true); + distancePriceField.setRequiredIndicatorVisible(true); + + NumberField timePriceField = new NumberField("Preis pro 15 Minuten (€)"); + timePriceField.setWidthFull(); + timePriceField.setMin(0); + timePriceField.setStep(0.01); + timePriceField.setValue(0.0); // Default 0.00 + timePriceField.setRequired(true); + timePriceField.setRequiredIndicatorVisible(true); + + // Set values if editing existing service + if (service != null) { + nameField.setValue(service.getName()); + calculationBasisCombo.setValue(service.getCalculationBasis()); + if (service.getVatRate() != null) { + vatRateField.setValue(service.getVatRate().multiply(new BigDecimal("100")).doubleValue()); + } + + // Set the appropriate price field based on calculation basis + if (service.getCalculationBasis() != null) { + switch (service.getCalculationBasis()) { + case DISTANCE: + if (service.getPricePerKilometer() != null) { + distancePriceField.setValue(service.getPricePerKilometer().doubleValue()); + } + break; + case TIME: + if (service.getPricePer15Minutes() != null) { + timePriceField.setValue(service.getPricePer15Minutes().doubleValue()); + } + break; + case FLAT_RATE: + if (service.getPrice() != null) { + flatRatePriceField.setValue(service.getPrice().doubleValue()); + } + break; + } + } + } + + // Add value change listener to show/hide appropriate price field based on + // calculation basis + calculationBasisCombo.addValueChangeListener(e -> { + Service.CalculationBasis selectedBasis = e.getValue(); + flatRatePriceField.setVisible(selectedBasis == Service.CalculationBasis.FLAT_RATE); + distancePriceField.setVisible(selectedBasis == Service.CalculationBasis.DISTANCE); + timePriceField.setVisible(selectedBasis == Service.CalculationBasis.TIME); + }); + + // Initialize price field visibility based on current selection + Service.CalculationBasis initialBasis = calculationBasisCombo.getValue(); + flatRatePriceField.setVisible(initialBasis == Service.CalculationBasis.FLAT_RATE); + distancePriceField.setVisible(initialBasis == Service.CalculationBasis.DISTANCE); + timePriceField.setVisible(initialBasis == Service.CalculationBasis.TIME); + + formLayout.add(nameField, calculationBasisCombo, flatRatePriceField, distancePriceField, timePriceField, + vatRateField, mandatoryCheckbox); + + // Action buttons + HorizontalLayout buttonLayout = new HorizontalLayout(); + buttonLayout.setWidthFull(); + buttonLayout.setJustifyContentMode(FlexComponent.JustifyContentMode.END); + buttonLayout.setSpacing(true); + + Button cancelButton = new Button("Abbrechen", e -> dialog.close()); + cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + + Button saveButton = new Button("Speichern", e -> { + if (validateServiceForm(nameField, calculationBasisCombo, flatRatePriceField, distancePriceField, + timePriceField, vatRateField, mandatoryCheckbox)) { + // Get the appropriate price based on calculation basis + BigDecimal priceValue = BigDecimal.ZERO; + Service.CalculationBasis selectedBasis = calculationBasisCombo.getValue(); + + if (selectedBasis == Service.CalculationBasis.FLAT_RATE && flatRatePriceField.getValue() != null) { + priceValue = new BigDecimal(flatRatePriceField.getValue()); + } else if (selectedBasis == Service.CalculationBasis.DISTANCE + && distancePriceField.getValue() != null) { + priceValue = new BigDecimal(distancePriceField.getValue()); + } else if (selectedBasis == Service.CalculationBasis.TIME && timePriceField.getValue() != null) { + priceValue = new BigDecimal(timePriceField.getValue()); + } + + BigDecimal vatRate = new BigDecimal(vatRateField.getValue()).divide(new BigDecimal("100")); + boolean mandatory = mandatoryCheckbox.getValue(); + saveService(service, nameField.getValue(), calculationBasisCombo.getValue(), priceValue, vatRate, + mandatory); + dialog.close(); + } + }); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + buttonLayout.add(cancelButton, saveButton); + formLayout.add(buttonLayout); + + dialog.add(formLayout); + dialog.open(); + } + + /** + * Validate service form fields + */ + private boolean validateServiceForm(TextField nameField, ComboBox calculationBasisCombo, + NumberField flatRatePriceField, NumberField distancePriceField, NumberField timePriceField, + NumberField vatRateField, Checkbox mandatoryCheckbox) { + boolean isValid = true; + + if (nameField.isEmpty()) { + nameField.setInvalid(true); + nameField.setErrorMessage("Name ist erforderlich"); + isValid = false; + } else { + nameField.setInvalid(false); + } + + if (calculationBasisCombo.isEmpty()) { + calculationBasisCombo.setInvalid(true); + calculationBasisCombo.setErrorMessage("Berechnungsgrundlage ist erforderlich"); + isValid = false; + } else { + calculationBasisCombo.setInvalid(false); + } + + // Validate the appropriate price field based on calculation basis + Service.CalculationBasis selectedBasis = calculationBasisCombo.getValue(); + if (selectedBasis == Service.CalculationBasis.FLAT_RATE && flatRatePriceField.isVisible()) { + if (flatRatePriceField.isEmpty() || flatRatePriceField.getValue() == null) { + flatRatePriceField.setInvalid(true); + flatRatePriceField.setErrorMessage("Pauschalpreis ist erforderlich"); + isValid = false; + } else { + flatRatePriceField.setInvalid(false); + } + } else if (selectedBasis == Service.CalculationBasis.DISTANCE && distancePriceField.isVisible()) { + if (distancePriceField.isEmpty() || distancePriceField.getValue() == null) { + distancePriceField.setInvalid(true); + distancePriceField.setErrorMessage("Preis pro Kilometer ist erforderlich"); + isValid = false; + } else { + distancePriceField.setInvalid(false); + } + } else if (selectedBasis == Service.CalculationBasis.TIME && timePriceField.isVisible()) { + if (timePriceField.isEmpty() || timePriceField.getValue() == null) { + timePriceField.setInvalid(true); + timePriceField.setErrorMessage("Preis pro 15 Minuten ist erforderlich"); + isValid = false; + } else { + timePriceField.setInvalid(false); + } + } + + if (vatRateField.isEmpty() || vatRateField.getValue() == null) { + vatRateField.setInvalid(true); + vatRateField.setErrorMessage("Mehrwertsteuersatz ist erforderlich"); + isValid = false; + } else { + vatRateField.setInvalid(false); + } + + return isValid; + } + + /** + * Save service to database + */ + private void saveService(Service existingService, String name, Service.CalculationBasis calculationBasis, + BigDecimal priceValue, BigDecimal vatRate, boolean mandatory) { + try { + Service service; + if (existingService != null) { + service = existingService; + service.setName(name); + service.setCalculationBasis(calculationBasis); + service.setVatRate(vatRate); + service.setMandatory(mandatory); + + // Set the appropriate price field based on calculation basis + switch (calculationBasis) { + case DISTANCE: + service.setPricePerKilometer(priceValue); + service.setPrice(null); + service.setPricePer15Minutes(null); + break; + case TIME: + service.setPricePer15Minutes(priceValue); + service.setPrice(null); + service.setPricePerKilometer(null); + break; + case FLAT_RATE: + service.setPrice(priceValue); + service.setPricePerKilometer(null); + service.setPricePer15Minutes(null); + break; + } + } else { + service = new Service(currentUser.getId().toString(), name, calculationBasis, priceValue, vatRate, + mandatory); + } + + serviceRepository.save(service); + Notification.show("Leistung erfolgreich gespeichert", 3000, Notification.Position.BOTTOM_CENTER); + + // Refresh the grid by reloading services + if (servicesGrid != null) { + loadServices(servicesGrid); + } + + } catch (Exception e) { + Notification.show("Fehler beim Speichern der Leistung: " + e.getMessage(), 5000, + Notification.Position.BOTTOM_CENTER); + } + } + + /** + * Delete a service + */ + private void deleteService(Service service) { + try { + serviceRepository.delete(service); + Notification.show("Leistung erfolgreich gelöscht", 3000, Notification.Position.BOTTOM_CENTER); + + // Refresh the grid by reloading services + if (servicesGrid != null) { + loadServices(servicesGrid); + } + + } catch (Exception e) { + Notification.show("Fehler beim Löschen der Leistung: " + e.getMessage(), 5000, + Notification.Position.BOTTOM_CENTER); + } + } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java b/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java index 649b6c2..6e6604c 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java @@ -49,10 +49,7 @@ public class InvoiceGeneratorView extends VerticalLayout { setMargin(false); setWidth("100%"); setHeight("100%"); - getStyle() - .set("overflow", "hidden") - .set("box-sizing", "border-box") - .set("display", "flex") + getStyle().set("overflow", "hidden").set("box-sizing", "border-box").set("display", "flex") .set("flex-direction", "column"); // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften) @@ -66,27 +63,19 @@ public class InvoiceGeneratorView extends VerticalLayout { VerticalLayout leftPanel = createTemplatesPanel(); leftPanel.setWidth("250px"); leftPanel.setHeightFull(); - leftPanel.getStyle() - .set("flex-shrink", "0") - .set("min-width", "250px") - .set("overflow", "auto"); + leftPanel.getStyle().set("flex-shrink", "0").set("min-width", "250px").set("overflow", "auto"); // Mitte: Canvas mit Konva.js VerticalLayout centerPanel = createCanvasPanel(); centerPanel.setWidth("60%"); centerPanel.setHeightFull(); - centerPanel.getStyle() - .set("flex-grow", "1") - .set("min-width", "0"); + centerPanel.getStyle().set("flex-grow", "1").set("min-width", "0"); // Rechte Seite: Eigenschaften propertiesPanel = createPropertiesPanel(); propertiesPanel.setWidth("300px"); propertiesPanel.setHeightFull(); - propertiesPanel.getStyle() - .set("flex-shrink", "0") - .set("min-width", "300px") - .set("overflow", "auto"); + propertiesPanel.getStyle().set("flex-shrink", "0").set("min-width", "300px").set("overflow", "auto"); mainLayout.add(leftPanel, centerPanel, propertiesPanel); mainLayout.expand(centerPanel); @@ -96,9 +85,7 @@ public class InvoiceGeneratorView extends VerticalLayout { // Aktions-Buttons unter dem Canvas (fixe Höhe) HorizontalLayout actionLayout = createActionButtons(); actionLayout.setHeight("60px"); - actionLayout.getStyle() - .set("flex-shrink", "0") - .set("padding", "0 var(--lumo-space-m)"); + actionLayout.getStyle().set("flex-shrink", "0").set("padding", "0 var(--lumo-space-m)"); add(actionLayout); } @@ -106,14 +93,9 @@ public class InvoiceGeneratorView extends VerticalLayout { protected void onAttach(AttachEvent attachEvent) { super.onAttach(attachEvent); // Register this view instance and initialize the canvas - getElement().executeJs( - "window.invoiceGeneratorView = this;" + - "if (window.invoiceGenerator) {" + - " console.log('Initializing invoice generator...');" + - " window.invoiceGenerator.init();" + - "} else {" + - " console.error('Invoice generator not found');" + - "}"); + getElement().executeJs("window.invoiceGeneratorView = this;" + "if (window.invoiceGenerator) {" + + " console.log('Initializing invoice generator...');" + " window.invoiceGenerator.init();" + + "} else {" + " console.error('Invoice generator not found');" + "}"); } private VerticalLayout createTemplatesPanel() { @@ -121,15 +103,11 @@ public class InvoiceGeneratorView extends VerticalLayout { panel.setPadding(true); panel.setSpacing(true); panel.setHeightFull(); - panel.getStyle() - .set("background-color", "var(--lumo-contrast-5pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("overflow", "auto"); + panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto"); Span header = new Span("Textbausteine"); - header.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-l)"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)"); // Draggable Templates Div textBlock = createDraggableTemplate("Textfeld", VaadinIcon.TEXT_LABEL, "text"); @@ -150,17 +128,10 @@ public class InvoiceGeneratorView extends VerticalLayout { private Div createDraggableTemplate(String label, VaadinIcon icon, String type) { Div template = new Div(); template.setText(label); - template.getStyle() - .set("padding", "var(--lumo-space-m)") - .set("margin", "var(--lumo-space-xs) 0") - .set("background-color", "var(--lumo-base-color)") - .set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("cursor", "grab") - .set("display", "flex") - .set("align-items", "center") - .set("gap", "var(--lumo-space-s)") - .set("user-select", "none"); + template.getStyle().set("padding", "var(--lumo-space-m)").set("margin", "var(--lumo-space-xs) 0") + .set("background-color", "var(--lumo-base-color)").set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("cursor", "grab").set("display", "flex") + .set("align-items", "center").set("gap", "var(--lumo-space-s)").set("user-select", "none"); // Icon hinzufügen Icon templateIcon = icon.create(); @@ -173,15 +144,12 @@ public class InvoiceGeneratorView extends VerticalLayout { template.getElement().setAttribute("data-template-label", label); // JavaScript Event Listener für Drag Start - template.getElement().executeJs( - "this.addEventListener('dragstart', function(e) {" + - " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + - " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + - " this.style.opacity = '0.5';" + - "});" + - "this.addEventListener('dragend', function(e) {" + - " this.style.opacity = '1';" + - "});"); + template.getElement() + .executeJs("this.addEventListener('dragstart', function(e) {" + + " e.dataTransfer.setData('template-type', this.getAttribute('data-template-type'));" + + " e.dataTransfer.setData('template-label', this.getAttribute('data-template-label'));" + + " this.style.opacity = '0.5';" + "});" + "this.addEventListener('dragend', function(e) {" + + " this.style.opacity = '1';" + "});"); return template; } @@ -197,37 +165,26 @@ public class InvoiceGeneratorView extends VerticalLayout { canvasContainer.setId("invoice-canvas-container"); canvasContainer.setWidth("100%"); canvasContainer.setHeight("100%"); - canvasContainer.getStyle() - .set("background-color", "#e8e8e8") + canvasContainer.getStyle().set("background-color", "#e8e8e8") .set("border", "2px dashed var(--lumo-contrast-30pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("position", "relative") - .set("overflow", "hidden") - .set("cursor", "default"); + .set("border-radius", "var(--lumo-border-radius-m)").set("position", "relative") + .set("overflow", "hidden").set("cursor", "default"); // Drop Zone Event Listener - canvasContainer.getElement().executeJs( - "var container = this;" + - "container.addEventListener('dragover', function(e) {" + - " e.preventDefault();" + - " e.dataTransfer.dropEffect = 'copy';" + - " container.style.borderColor = 'var(--lumo-primary-color)';" + - "});" + - "container.addEventListener('dragleave', function(e) {" + - " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + - "});" + - "container.addEventListener('drop', function(e) {" + - " e.preventDefault();" + - " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + - " var templateType = e.dataTransfer.getData('template-type');" + - " var templateLabel = e.dataTransfer.getData('template-label');" + - " if (templateType && window.invoiceGenerator) {" + - " var rect = container.getBoundingClientRect();" + - " var x = e.clientX - rect.left;" + - " var y = e.clientY - rect.top;" + - " window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" + - " }" + - "});"); + canvasContainer.getElement() + .executeJs("var container = this;" + "container.addEventListener('dragover', function(e) {" + + " e.preventDefault();" + " e.dataTransfer.dropEffect = 'copy';" + + " container.style.borderColor = 'var(--lumo-primary-color)';" + "});" + + "container.addEventListener('dragleave', function(e) {" + + " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + "});" + + "container.addEventListener('drop', function(e) {" + " e.preventDefault();" + + " container.style.borderColor = 'var(--lumo-contrast-30pct)';" + + " var templateType = e.dataTransfer.getData('template-type');" + + " var templateLabel = e.dataTransfer.getData('template-label');" + + " if (templateType && window.invoiceGenerator) {" + + " var rect = container.getBoundingClientRect();" + " var x = e.clientX - rect.left;" + + " var y = e.clientY - rect.top;" + + " window.invoiceGenerator.addElement(templateType, templateLabel, x, y);" + " }" + "});"); panel.add(canvasContainer); panel.expand(canvasContainer); @@ -240,22 +197,17 @@ public class InvoiceGeneratorView extends VerticalLayout { panel.setPadding(true); panel.setSpacing(true); panel.setHeightFull(); - panel.getStyle() - .set("background-color", "var(--lumo-contrast-5pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("overflow", "auto"); + panel.getStyle().set("background-color", "var(--lumo-contrast-5pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("overflow", "auto"); Span header = new Span("Eigenschaften"); - header.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-l)"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)"); // Info-Text wenn kein Element ausgewählt selectedElementInfo = new Div(); selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten."); - selectedElementInfo.getStyle() - .set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)"); + selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", + "var(--lumo-font-size-s)"); panel.add(header, selectedElementInfo); @@ -362,36 +314,29 @@ public class InvoiceGeneratorView extends VerticalLayout { Div pdfContainer = new Div(); pdfContainer.setWidth("100%"); pdfContainer.setHeight("100%"); - pdfContainer.getStyle() - .set("display", "flex") - .set("flex-direction", "column") - .set("overflow", "hidden"); + pdfContainer.getStyle().set("display", "flex").set("flex-direction", "column").set("overflow", "hidden"); // Use an iframe with data URL for PDF display String base64Pdf = java.util.Base64.getEncoder().encodeToString(pdfBytes); String dataUrl = "data:application/pdf;base64," + base64Pdf; - + IFrame pdfFrame = new IFrame(); pdfFrame.setWidth("100%"); pdfFrame.setHeight("100%"); pdfFrame.getElement().setAttribute("src", dataUrl); - pdfFrame.getStyle() - .set("border", "none") - .set("flex-grow", "1"); + pdfFrame.getStyle().set("border", "none").set("flex-grow", "1"); pdfContainer.add(pdfFrame); // Close button Button closeButton = new Button("Schließen", e -> pdfDialog.close()); closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - + // Download button Button downloadButton = new Button("Herunterladen", e -> { - getElement().executeJs( - "const link = document.createElement('a');" + - "link.href = 'data:application/pdf;base64," + base64Pdf + "';" + - "link.download = 'vorschau.pdf';" + - "link.click();"); + getElement() + .executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64," + + base64Pdf + "';" + "link.download = 'vorschau.pdf';" + "link.click();"); }); downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); @@ -412,9 +357,7 @@ public class InvoiceGeneratorView extends VerticalLayout { propertiesPanel.removeAll(); Span header = new Span("Eigenschaften"); - header.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-l)"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)"); // Element Typ Anzeige Span typeLabel = new Span("Typ: " + elementType); @@ -430,7 +373,7 @@ public class InvoiceGeneratorView extends VerticalLayout { upload.setMaxFileSize(5 * 1024 * 1024); // 5 MB upload.setDropLabel(new Span("Bild hierher ziehen oder klicken")); upload.setWidthFull(); - + upload.addSucceededListener(event -> { try { // Bild als Base64 kodieren @@ -438,22 +381,21 @@ public class InvoiceGeneratorView extends VerticalLayout { String base64 = java.util.Base64.getEncoder().encodeToString(bytes); String mimeType = event.getMIMEType(); String dataUrl = "data:" + mimeType + ";base64," + base64; - + // An JavaScript übergeben - getElement().executeJs( - "if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('" - + elementId + "', $0); }", - dataUrl); + getElement() + .executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementImage('" + + elementId + "', $0); }", dataUrl); showNotification("Bild erfolgreich hochgeladen"); } catch (Exception ex) { showNotification("Fehler beim Hochladen: " + ex.getMessage()); } }); - + upload.addFileRejectedListener(event -> { showNotification("Datei abgelehnt: " + event.getErrorMessage()); }); - + propertiesPanel.add(upload); } @@ -463,10 +405,8 @@ public class InvoiceGeneratorView extends VerticalLayout { textField.setValue(text != null ? text : ""); textField.setWidthFull(); textField.addValueChangeListener(e -> { - getElement().executeJs( - "if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('" + elementId - + "', $0); }", - e.getValue()); + getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementText('" + + elementId + "', $0); }", e.getValue()); }); propertiesPanel.add(textField); } @@ -478,10 +418,9 @@ public class InvoiceGeneratorView extends VerticalLayout { xField.addValueChangeListener(e -> { try { double newX = Double.parseDouble(e.getValue()); - getElement().executeJs( - "if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId - + "', $0, null); }", - newX); + getElement() + .executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + + elementId + "', $0, null); }", newX); } catch (NumberFormatException ignored) { } }); @@ -494,10 +433,9 @@ public class InvoiceGeneratorView extends VerticalLayout { yField.addValueChangeListener(e -> { try { double newY = Double.parseDouble(e.getValue()); - getElement().executeJs( - "if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + elementId - + "', null, $0); }", - newY); + getElement() + .executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementPosition('" + + elementId + "', null, $0); }", newY); } catch (NumberFormatException ignored) { } }); @@ -524,69 +462,61 @@ public class InvoiceGeneratorView extends VerticalLayout { Span colorLabel = new Span("Schriftfarbe"); colorLabel.getStyle().set("font-size", "var(--lumo-font-size-s)"); propertiesPanel.add(colorLabel); - + // Aktuelle Farbe anzeigen und klickbar machen String currentColor = color != null ? color : "#333333"; - + HorizontalLayout colorPreviewLayout = new HorizontalLayout(); colorPreviewLayout.setSpacing(true); colorPreviewLayout.setAlignItems(Alignment.CENTER); colorPreviewLayout.setWidthFull(); colorPreviewLayout.getStyle().set("cursor", "pointer"); - + // Farbvorschau-Box Div colorPreview = new Div(); - colorPreview.getStyle() - .set("width", "40px") - .set("height", "30px") - .set("background-color", currentColor) + colorPreview.getStyle().set("width", "40px").set("height", "30px").set("background-color", currentColor) .set("border", "1px solid var(--lumo-contrast-30pct)") .set("border-radius", "var(--lumo-border-radius-m)"); - + Span colorHexLabel = new Span(currentColor); - colorHexLabel.getStyle() - .set("font-family", "monospace") - .set("font-size", "var(--lumo-font-size-s)"); - + colorHexLabel.getStyle().set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)"); + colorPreviewLayout.add(colorPreview, colorHexLabel); - + // Color Picker Dialog Dialog colorDialog = new Dialog(); colorDialog.setHeaderTitle("Schriftfarbe wählen"); - + VerticalLayout dialogLayout = new VerticalLayout(); dialogLayout.setSpacing(true); dialogLayout.setPadding(true); - + // Color Picker im Dialog Input dialogColorPicker = new Input(); dialogColorPicker.setType("color"); dialogColorPicker.setValue(currentColor); - dialogColorPicker.getStyle() - .set("width", "100%") - .set("height", "50px") - .set("padding", "0"); - + dialogColorPicker.getStyle().set("width", "100%").set("height", "50px").set("padding", "0"); + // Hex-Eingabe im Dialog TextField dialogHexField = new TextField("Hex-Farbwert"); dialogHexField.setValue(currentColor); dialogHexField.setWidthFull(); - + // Sync zwischen Color Picker und Hex-Feld dialogColorPicker.addValueChangeListener(e -> { dialogHexField.setValue(e.getValue()); }); - + dialogHexField.addValueChangeListener(e -> { String newColor = e.getValue(); if (newColor.matches("^#[0-9A-Fa-f]{6}$")) { dialogColorPicker.setValue(newColor); } }); - + dialogLayout.add(dialogColorPicker, dialogHexField); colorDialog.add(dialogLayout); - + // Dialog Buttons Button dialogCancelButton = new Button("Abbrechen", e -> { colorDialog.close(); @@ -595,24 +525,22 @@ public class InvoiceGeneratorView extends VerticalLayout { dialogHexField.setValue(currentColor); }); dialogCancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - + Button dialogApplyButton = new Button("Übernehmen", e -> { String newColor = dialogColorPicker.getValue(); // Update preview colorPreview.getStyle().set("background-color", newColor); colorHexLabel.setText(newColor); // Apply to element - getElement().executeJs( - "if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('" - + elementId + "', $0); }", - newColor); + getElement().executeJs("if (window.invoiceGenerator) { window.invoiceGenerator.updateElementColor('" + + elementId + "', $0); }", newColor); colorDialog.close(); showNotification("Farbe übernommen"); }); dialogApplyButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - + colorDialog.getFooter().add(dialogCancelButton, dialogApplyButton); - + // Öffne Dialog beim Klick auf die Vorschau Runnable openColorDialog = () -> { // Aktualisiere Dialog mit aktuellem Wert @@ -621,11 +549,11 @@ public class InvoiceGeneratorView extends VerticalLayout { dialogHexField.setValue(actualColor); colorDialog.open(); }; - + colorPreviewLayout.addClickListener(e -> openColorDialog.run()); colorPreview.addClickListener(e -> openColorDialog.run()); colorHexLabel.addClickListener(e -> openColorDialog.run()); - + propertiesPanel.add(colorPreviewLayout); } @@ -635,8 +563,7 @@ public class InvoiceGeneratorView extends VerticalLayout { deleteButton.setWidthFull(); deleteButton.addClickListener(e -> { getElement().executeJs( - "if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId - + "'); }"); + "if (window.invoiceGenerator) { window.invoiceGenerator.deleteElement('" + elementId + "'); }"); resetPropertiesPanel(); }); propertiesPanel.add(deleteButton); @@ -652,15 +579,13 @@ public class InvoiceGeneratorView extends VerticalLayout { propertiesPanel.removeAll(); Span header = new Span("Eigenschaften"); - header.getStyle() - .set("font-weight", "bold") - .set("font-size", "var(--lumo-font-size-l)"); + header.getStyle().set("font-weight", "bold").set("font-size", "var(--lumo-font-size-l)"); selectedElementInfo = new Div(); - selectedElementInfo.setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten."); - selectedElementInfo.getStyle() - .set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)"); + selectedElementInfo + .setText("Klicken Sie auf ein Element im Canvas, um dessen Eigenschaften zu bearbeiten."); + selectedElementInfo.getStyle().set("color", "var(--lumo-secondary-text-color)").set("font-size", + "var(--lumo-font-size-s)"); propertiesPanel.add(header, selectedElementInfo); })); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index 7fb6a1c..c52b17e 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -66,1134 +66,1126 @@ import java.util.Locale; @Slf4j public class JobSummaryView extends Main implements HasUrlParameter { - private final JobRepository jobRepository; - private final CargoItemRepository cargoItemRepository; - private final TaskRepository taskRepository; - private final SignatureRepository signatureRepository; - private final BarcodeRepository barcodeRepository; - private final PhotoRepository photoRepository; - private final CommentRepository commentRepository; - private final AppUserService appUserService; - private final JobHistoryService jobHistoryService; - private final LocationService locationService; - - @Value("${app.google.maps.api-key}") - private String googleMapsApiKey; - - private final VerticalLayout content; - private final List
taskCards = new ArrayList<>(); - - public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, - TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, - PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, - MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService) { - this.jobRepository = jobRepository; - this.cargoItemRepository = cargoItemRepository; - this.taskRepository = taskRepository; - this.signatureRepository = signatureRepository; - this.barcodeRepository = barcodeRepository; - this.photoRepository = photoRepository; - this.commentRepository = commentRepository; - this.appUserService = appUserService; - this.jobHistoryService = jobHistoryService; - this.locationService = locationService; - - setSizeFull(); - addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, - LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL); - - content = new VerticalLayout(); - content.setSpacing(true); - content.setPadding(true); - content.setWidthFull(); - } - - @Override - public void setParameter(BeforeEvent event, String parameter) { - content.removeAll(); - removeAll(); // Remove existing toolbar - - if (parameter == null || parameter.isBlank()) { - add(new ViewToolbar("Zusammenfassung")); - content.add(new Span("Fehler: Keine Job-ID angegeben")); - add(content); - return; - } - - ObjectId jobId; - try { - jobId = new ObjectId(parameter); - } catch (Exception e) { - add(new ViewToolbar("Zusammenfassung")); - content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter)); - add(content); - return; - } - - Job job = jobRepository.findById(jobId).orElse(null); - if (job == null) { - add(new ViewToolbar("Zusammenfassung")); - content.add(new Span("Fehler: Job mit ID " + parameter + " nicht gefunden")); - add(content); - return; - } - - // Create Send Message Button for toolbar - Button sendMessageButton = new Button("Nachricht senden"); - sendMessageButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - sendMessageButton.addClickListener(e -> { - // Check if job has an app user assigned - if (job.getAppUser() == null || job.getAppUser().isBlank()) { - Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE) - .addThemeVariants(NotificationVariant.LUMO_ERROR); - return; - } - - String appUserId = job.getAppUser(); - String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : job.getId().toHexString(); - - // Navigate to message details view with job conversation - // Format: message-details/{clientId}/job-{jobNumber} - String conversationId = "job-" + jobNumber; - getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId)); - }); - - // Create Job History Button for toolbar - Button jobHistoryButton = new Button("Job Historie"); - jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - jobHistoryButton.addClickListener(e -> { - getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString())); - }); - - // Add toolbar with both buttons in top right (Send Message button on the left) - add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton)); - - List cargo = cargoItemRepository.findByJobId(jobId); - List tasks = taskRepository.findByJobIdOrderByTaskOrderAsc(jobId); - - render(job, cargo, tasks); - add(content); - } - - private void render(Job job, List cargoItems, List tasks) { - content.removeAll(); - - // Kopfzeile: Abholung/Lieferung - HorizontalLayout topRow = new HorizontalLayout(); - topRow.setWidthFull(); - topRow.setSpacing(true); - - VerticalLayout pickupBox = borderedBox(); - pickupBox.add(new H3("Abholung " + formatDateWithTime(job.getPickupDate(), job.getPickupTime()))); - pickupBox.add(new Span(valueOrEmpty(job.getPickupCompany()))); - pickupBox.add(new Span(valueOrEmpty(job.getPickupSalutation()) + (job.getPickupSalutation() != null ? " " : "") - + valueOrEmpty(job.getPickupFirstName()) + (job.getPickupFirstName() != null ? " " : "") - + valueOrEmpty(job.getPickupLastName()))); - pickupBox.add(new Span(concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()))); - pickupBox.add(new Span(concatZipCity(job.getPickupZip(), job.getPickupCity()))); - - VerticalLayout deliveryBox = borderedBox(); - deliveryBox.add(new H3("Lieferung " + formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime()))); - deliveryBox.add(new Span(valueOrEmpty(job.getDeliveryCompany()))); - deliveryBox.add(new Span(valueOrEmpty(job.getDeliverySalutation()) - + (job.getDeliverySalutation() != null ? " " : "") + valueOrEmpty(job.getDeliveryFirstName()) - + (job.getDeliveryFirstName() != null ? " " : "") + valueOrEmpty(job.getDeliveryLastName()))); - deliveryBox.add(new Span(concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()))); - deliveryBox.add(new Span(concatZipCity(job.getDeliveryZip(), job.getDeliveryCity()))); - - pickupBox.setWidth("50%"); - deliveryBox.setWidth("50%"); - topRow.add(pickupBox, deliveryBox); - content.add(topRow); - - // Aufgaben - VerticalLayout tasksBox = borderedBox(); - tasksBox.add(new H3("Zu quittierende Aufgaben")); - - // Ensure consistent spacing and width for task cards - tasksBox.setSpacing(false); - tasksBox.getStyle().set("gap", "var(--lumo-space-xs)"); - - // Clear previous task cards - taskCards.clear(); - - if (tasks == null || tasks.isEmpty()) { - tasksBox.add(new Span("Keine Aufgaben")); - } else { - for (BaseTask task : tasks) { - if (task != null) { - // Use getDisplayName() instead of getText() for task display - String displayName = task.getDisplayName(); - if (displayName != null && !displayName.isBlank()) { - Div taskCard = createTaskCard(task, displayName); - taskCards.add(taskCard); // Keep reference for hover reset - tasksBox.add(taskCard); - } - } - } - } - content.add(tasksBox); - - // Fracht und weitere Infos - HorizontalLayout midRow = new HorizontalLayout(); - midRow.setWidthFull(); - midRow.setSpacing(true); - - VerticalLayout cargoBox = borderedBox(); - cargoBox.add(new H3("Zu transportierende Fracht")); - if (cargoItems == null || cargoItems.isEmpty()) { - cargoBox.add(new Span("Keine Frachtangaben")); - } else { - for (CargoItem ci : cargoItems) { - if (ci == null) - continue; - String desc = ci.getDescription(); - Integer qty = ci.getQuantity(); - String dims = dimString(ci); - String weight = ci.getWeightKg() != null ? ci.getWeightKg() + " kg" : ""; - String line = (qty != null ? qty + " x " : "") + (desc != null ? desc : "") - + (dims.isBlank() ? "" : " " + dims) + (weight.isBlank() ? "" : " " + weight); - if (!line.isBlank()) - cargoBox.add(new Span(line)); - } - } - - VerticalLayout infoBox = borderedBox(); - infoBox.add(new H3("Weitere Informationen")); - infoBox.add(new Span("Preis: " + (job.getPrice() != null ? formatPrice(job.getPrice()) : "-"))); - if (job.getRemark() != null && !job.getRemark().isBlank()) { - infoBox.add(new Span("Bemerkung: " + job.getRemark())); - } - if (job.isDigitalProcessing()) { - infoBox.add(new Span("Digitale Abwicklung per App: aktiviert")); - } - if (job.getAppUser() != null && !job.getAppUser().isBlank()) { - infoBox.add(new Span("App-Nutzer: " + resolveAppUserName(job.getAppUser()))); - } - - cargoBox.setWidth("50%"); - infoBox.setWidth("50%"); - midRow.add(cargoBox, infoBox); - content.add(midRow); - - // Google Maps Karte mit Route - addRouteMap(job); - - // Manual completion button for jobs without digital processing - if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED - && job.getStatus() != JobStatus.CANCELLED) { - HorizontalLayout buttonRow = new HorizontalLayout(); - buttonRow.setWidthFull(); - buttonRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER); - buttonRow.getStyle().set("margin-top", "var(--lumo-space-l)"); - - Button completeButton = new Button("Auftrag manuell abschließen", new Icon(VaadinIcon.CHECK_CIRCLE)); - completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); - completeButton.addClickListener(e -> { - ConfirmDialog dialog = new ConfirmDialog(); - dialog.setHeader("Auftrag abschließen"); - dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?"); - dialog.setCancelable(true); - dialog.setCancelText("Abbrechen"); - dialog.setConfirmText("Abschließen"); - dialog.setConfirmButtonTheme("primary"); - dialog.addConfirmListener(ev -> { - try { - JobStatus oldStatus = job.getStatus(); - job.setStatus(JobStatus.COMPLETED); - job.setUpdatedAt(LocalDateTime.now()); - jobRepository.save(job); - jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell"); - Notification - .show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000, - Notification.Position.BOTTOM_END) - .addThemeVariants(NotificationVariant.LUMO_SUCCESS); - // Re-render the page - getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())); - } catch (Exception ex) { - Notification - .show("Fehler beim Abschließen: " + ex.getMessage(), 5000, - Notification.Position.BOTTOM_END) - .addThemeVariants(NotificationVariant.LUMO_ERROR); - } - }); - dialog.open(); - }); - - buttonRow.add(completeButton); - content.add(buttonRow); - } - } - - private VerticalLayout borderedBox() { - VerticalLayout box = new VerticalLayout(); - box.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); - box.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); - box.getStyle().set("background-color", "var(--lumo-base-color)"); - box.setPadding(true); - box.setSpacing(false); - return box; - } - - private String formatLocalDate(java.time.LocalDate date) { - try { - return DateTimeFormatUtil.formatDate(date); - } catch (Exception e) { - return ""; - } - } - - private String formatLocalTime(java.time.LocalTime time) { - try { - return DateTimeFormatUtil.formatTime(time); - } catch (Exception e) { - return ""; - } - } - - private String formatDateWithTime(java.time.LocalDate date, java.time.LocalTime time) { - StringBuilder sb = new StringBuilder(); - if (date != null) { - sb.append(formatLocalDate(date)); - if (time != null) { - sb.append(", ").append(formatLocalTime(time)); - } - } - return sb.toString(); - } - - private String valueOrEmpty(String v) { - return v == null ? "" : v; - } - - private String concatAddress(String street, String house) { - String s = valueOrEmpty(street); - String h = valueOrEmpty(house); - return (s + (h.isBlank() ? "" : " " + h)).trim(); - } - - private String concatZipCity(String zip, String city) { - String z = valueOrEmpty(zip); - String c = valueOrEmpty(city); - if (!z.isBlank() && !c.isBlank()) - return z + " " + c; - return (z + " " + c).trim(); - } - - private String dimString(CargoItem ci) { - // Values are stored in cm (not mm), so display directly without division - String len = ci.getLengthMm() != null ? ci.getLengthMm().intValue() + " cm" : ""; - String wid = ci.getWidthMm() != null ? ci.getWidthMm().intValue() + " cm" : ""; - String hei = ci.getHeightMm() != null ? ci.getHeightMm().intValue() + " cm" : ""; - String combined = String.join(" x ", - java.util.stream.Stream.of(len, wid, hei).filter(s -> !s.isBlank()).toList()); - return combined.isBlank() ? "" : combined; - } - - private String formatPrice(java.math.BigDecimal price) { - java.text.NumberFormat nf = java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY); - return nf.format(price); - } - - private String resolveAppUserName(String appUserIdString) { - try { - ObjectId id = new ObjectId(appUserIdString); - AppUser au = appUserService.findById(id); - if (au != null) { - String fn = au.getVorname(); - String ln = au.getNachname(); - String name = (fn != null ? fn : "").trim() + (fn != null && ln != null ? " " : "") - + (ln != null ? ln : ""); - if (!name.isBlank()) - return name; - if (au.getBezeichnung() != null && !au.getBezeichnung().isBlank()) - return au.getBezeichnung(); - if (au.getEmail() != null && !au.getEmail().isBlank()) - return au.getEmail(); - } - } catch (Exception e) { - log.debug("Failed to resolve AppUser name for ID {}: {}", appUserIdString, e.getMessage()); - } - return appUserIdString; // Fallback: show raw string if lookup fails - } - - private void addRouteMap(Job job) { - // Baue Adress-Strings - String origin = (concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()) + ", " - + concatZipCity(job.getPickupZip(), job.getPickupCity())).trim(); - String destination = (concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()) + ", " - + concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim(); - - if (origin.isBlank() || destination.isBlank()) { - return; - } - - // Prüfe ob App-Tracking aktiviert ist und Job nicht erledigt/storniert - LocationPosition appUserPosition = null; - boolean showAppUserPosition = job.isDigitalProcessing() - && job.getStatus() != JobStatus.COMPLETED - && job.getStatus() != JobStatus.CANCELLED - && job.getAppUser() != null - && !job.getAppUser().isBlank(); - - if (showAppUserPosition) { - appUserPosition = locationService.getLatestPosition(job.getAppUser()); - } - - Div map = new Div(); - map.setWidthFull(); - map.setHeight("520px"); - map.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); - map.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); - - Div routeInfo = new Div(); - routeInfo.setWidthFull(); - routeInfo.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); - routeInfo.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); - routeInfo.getStyle().set("padding", "var(--lumo-space-m)"); - routeInfo.getStyle().set("background-color", "var(--lumo-base-color)"); - - content.add(map, routeInfo); - - // Position für JavaScript vorbereiten - final LocationPosition position = appUserPosition; - final boolean hasPosition = position != null && position.getLatitude() != null && position.getLongitude() != null; - final String appUserId = showAppUserPosition ? job.getAppUser() : ""; - final boolean shouldUpdate = showAppUserPosition; - - String js = buildMapJs(origin, destination, hasPosition, position, appUserId, shouldUpdate); - - map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); - } - - private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position, String appUserId, boolean shouldUpdate) { - String apiKey = getGoogleMapsApiKey(); - // Explizit mit Punkt als Dezimaltrennzeichen formatieren - String lat = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLatitude()) : "0"; - String lng = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLongitude()) : "0"; - - return """ - (function(){ - var host = $0; - var infoEl = $1; - var origin = '%s'; - var destination = '%s'; - var apiKey = '%s'; - var appUserLat = %s; - var appUserLng = %s; - var hasAppUserPos = %s; - var appUserId = '%s'; - var shouldUpdate = %s; - - var appUserMarker = null; - var updateInterval = null; - var map = null; - - function init(){ - map = new google.maps.Map(host, { - center: {lat: 51.163, lng: 10.447}, - zoom: 6, - mapTypeControl: false - }); - - var trafficLayer = new google.maps.TrafficLayer(); - trafficLayer.setMap(map); - - var ds = new google.maps.DirectionsService(); - ds.route({ - origin: origin, - destination: destination, - travelMode: google.maps.TravelMode.DRIVING, - provideRouteAlternatives: true, - drivingOptions: { - departureTime: new Date(), - trafficModel: google.maps.TrafficModel.BEST_GUESS - } - }, function(res, status){ - if(status === 'OK'){ - infoEl.innerHTML = ''; - var bounds = new google.maps.LatLngBounds(); - var renderers = []; - var polylines = []; - - res.routes.forEach(function(route, idx){ - var dr = new google.maps.DirectionsRenderer({ - map: map, - preserveViewport: idx > 0, - suppressMarkers: false, - suppressPolylines: true - }); - dr.setRouteIndex(idx); - dr.setDirections(res); - renderers.push(dr); - - var path = route.overview_path || []; - var poly = new google.maps.Polyline({ - path: path, - strokeColor: idx === 0 ? '#1976d2' : '#90caf9', - strokeOpacity: 0.95, - strokeWeight: idx === 0 ? 6 : 4 - }); - poly.setMap(map); - polylines.push(poly); - - var leg = route.legs && route.legs[0]; - if(leg){ - var dur = leg.duration ? leg.duration.text : ''; - var durT = leg.duration_in_traffic ? leg.duration_in_traffic.text : ''; - var dist = leg.distance ? leg.distance.text : ''; - var alt = (idx === 0 ? 'Schnellste Route' : 'Alternative ' + idx); - - var row = document.createElement('div'); - row.style.margin = '4px 0'; - row.style.cursor = 'pointer'; - row.textContent = alt + ': ' + dist + ' • Dauer: ' + dur + (durT ? ' (mit Verkehr: ' + durT + ')' : ''); - - row.onmouseenter = function(){ - polylines.forEach(function(p, i){ - p.setOptions({ - strokeColor: i === 0 ? '#90caf9' : '#e3f2fd', - strokeOpacity: 0.6, - strokeWeight: 3 - }); - }); - poly.setOptions({ - strokeColor: '#0d47a1', - strokeOpacity: 1, - strokeWeight: 7 - }); - }; - - row.onmouseleave = function(){ - polylines.forEach(function(p, i){ - p.setOptions({ - strokeColor: i === 0 ? '#1976d2' : '#90caf9', - strokeOpacity: 0.95, - strokeWeight: i === 0 ? 6 : 4 - }); - }); - }; - - infoEl.appendChild(row); - - if(path && path.length){ - path.forEach(function(pt){ bounds.extend(pt); }); - } - } - }); - - // App-Nutzer Position Marker - if(hasAppUserPos){ - createOrUpdateAppUserMarker(appUserLat, appUserLng); - bounds.extend({lat: appUserLat, lng: appUserLng}); - } - - if(!bounds.isEmpty()){ - map.fitBounds(bounds); - } - - // Alle 30 Sekunden aktualisieren - if(shouldUpdate && appUserId){ - startPositionUpdates(appUserId); - } - } - }); - } - - function createOrUpdateAppUserMarker(lat, lng){ - if(appUserMarker){ - appUserMarker.setPosition({lat: lat, lng: lng}); - } else { - appUserMarker = new google.maps.Marker({ - position: {lat: lat, lng: lng}, - map: map, - title: 'Position App-Nutzer', - icon: { - path: google.maps.SymbolPath.CIRCLE, - scale: 10, - fillColor: '#4caf50', - fillOpacity: 1, - strokeColor: '#ffffff', - strokeWeight: 2 - } - }); - - var infoWindow = new google.maps.InfoWindow({ - content: '
Position App-Nutzer
' - }); - - appUserMarker.addListener('click', function(){ - infoWindow.open(map, appUserMarker); - }); - } - } - - function startPositionUpdates(userId){ - updateInterval = setInterval(function(){ - fetch('/api/location/' + encodeURIComponent(userId)) - .then(function(response){ - if(!response.ok) throw new Error('No position'); - return response.json(); - }) - .then(function(data){ - if(data && data.latitude && data.longitude){ - createOrUpdateAppUserMarker(data.latitude, data.longitude); - } - }) - .catch(function(err){ - console.log('Location update failed:', err); - }); - }, 30000); - } - - if(!(window.google && window.google.maps)){ - var s = document.createElement('script'); - s.src = 'https://maps.googleapis.com/maps/api/js?key=' + apiKey + '&libraries=places'; - s.onload = init; - document.head.appendChild(s); - } else { - init(); - } - })(); - """.formatted( - escapeJs(origin), - escapeJs(destination), - escapeJs(apiKey), - lat, - lng, - Boolean.toString(hasPosition), - escapeJs(appUserId), - Boolean.toString(shouldUpdate) - ); - } - - // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings - private String escapeJs(String s) { - if (s == null) - return ""; - return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", " ").replace("\r", " "); - } - - private void showTaskDetailsDialog(BaseTask task) { - Dialog dialog = new Dialog(); - dialog.setWidth("500px"); - dialog.setResizable(true); - dialog.setDraggable(true); - - // Reset all task card hover states when dialog closes - dialog.addDialogCloseActionListener(e -> resetAllTaskCardHoverStates()); - - VerticalLayout dialogContent = new VerticalLayout(); - dialogContent.setPadding(true); - dialogContent.setSpacing(true); - - // Header - H4 header = new H4("Aufgaben-Details"); - dialogContent.add(header); - - // Task type and status - Span typeSpan = new Span("Typ: " + task.getDisplayName()); - typeSpan.getStyle().set("font-weight", "bold"); - dialogContent.add(typeSpan); - - Span statusSpan = new Span("Status: " + (task.isCompleted() ? "Abgeschlossen" : "Offen")); - if (task.isCompleted()) { - statusSpan.getStyle().set("color", "var(--lumo-success-text-color)"); - } else { - statusSpan.getStyle().set("color", "var(--lumo-error-text-color)"); - } - dialogContent.add(statusSpan); - - // Task-specific details - addTaskSpecificDetails(dialogContent, task); - - // Completion details if completed - if (task.isCompleted()) { - dialogContent.add(new Span("")); // Spacer - if (task.getCompletedAt() != null) { - dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt()))); - } - if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) { - dialogContent.add(new Span("Abgeschlossen von: " + task.getCompletedBy())); - } - } - - // Close button - Button closeButton = new Button("Schließen", e -> { - dialog.close(); - resetAllTaskCardHoverStates(); - }); - closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - - HorizontalLayout buttonLayout = new HorizontalLayout(closeButton); - buttonLayout.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END); - dialogContent.add(buttonLayout); - - dialog.add(dialogContent); - dialog.open(); - } - - private void addTaskSpecificDetails(VerticalLayout content, BaseTask task) { - if (task instanceof TodoListTask todoTask) { - content.add(new Span("To-Do Items:")); - if (todoTask.getTodoItems() != null && !todoTask.getTodoItems().isEmpty()) { - for (String item : todoTask.getTodoItems()) { - if (item != null && !item.isBlank()) { - Span itemSpan = new Span(" • " + item); - itemSpan.getStyle().set("margin-left", "20px"); - content.add(itemSpan); - } - } - } else { - content.add(new Span(" Keine Items definiert")); - } - } else if (task instanceof PhotoTask photoTask) { - if (photoTask.getMinPhotoCount() != null || photoTask.getMaxPhotoCount() != null) { - String photoInfo = "Fotos: "; - if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { - photoInfo += photoTask.getMinPhotoCount() + " - " + photoTask.getMaxPhotoCount() - + " Fotos erforderlich"; - } else if (photoTask.getMinPhotoCount() != null) { - photoInfo += "Mindestens " + photoTask.getMinPhotoCount() + " Fotos erforderlich"; - } else if (photoTask.getMaxPhotoCount() != null) { - photoInfo += "Maximal " + photoTask.getMaxPhotoCount() + " Fotos erlaubt"; - } - content.add(new Span(photoInfo)); - } - - // Show photos if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List photos = photoRepository.findByTaskId(taskId); - - if (!photos.isEmpty()) { - content.add(new Span("")); // Spacer - - // Collect all photos from all Photo entries - List allPhotos = new ArrayList<>(); - for (Photo photo : photos) { - if (photo.getPhoto() != null && !photo.getPhoto().isBlank()) { - allPhotos.add(photo.getPhoto()); - } - } - - if (!allPhotos.isEmpty()) { - content.add(new Span("Aufgenommene Fotos (" + allPhotos.size() + "):")); - - // Create photo gallery container - Div photoGallery = createPhotoGallery(allPhotos); - content.add(photoGallery); - } - } - } catch (Exception e) { - log.debug("Failed to load photos for task {}: {}", task.getId(), e.getMessage()); - } - } - } else if (task instanceof ConfirmationTask confirmationTask) { - if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { - content.add(new Span("Button-Text: " + confirmationTask.getButtonText())); - } - } else if (task instanceof SignatureTask) { - content.add(new Span("Unterschrift erforderlich")); - - // Show signature if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List signatures = signatureRepository.findByTaskId(taskId); - - if (!signatures.isEmpty()) { - content.add(new Span("")); // Spacer - content.add(new Span("Gespeicherte Unterschrift:")); - - // Display the latest signature (assuming one signature per task) - Signature signature = signatures.get(signatures.size() - 1); - String svgContent = signature.getSignatureSvg(); - - if (svgContent != null && !svgContent.isBlank()) { - // Create a div to hold the SVG - Div svgContainer = new Div(); - svgContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)") - .set("padding", "var(--lumo-space-s)").set("background-color", "white") - .set("width", "100%").set("max-width", "450px").set("overflow", "hidden") - .set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - - // Process SVG to make it responsive - String responsiveSvg = makeResponsiveSvg(svgContent); - svgContainer.getElement().setProperty("innerHTML", responsiveSvg); - content.add(svgContainer); - } - } - } catch (Exception e) { - log.debug("Failed to load signature for task {}: {}", task.getId(), e.getMessage()); - } - } - } else if (task instanceof BarcodeTask) { - content.add(new Span("Barcode-Scan erforderlich")); - - // Show barcodes if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List barcodes = barcodeRepository.findByTaskId(taskId); - - if (!barcodes.isEmpty()) { - content.add(new Span("")); // Spacer - content.add(new Span("Gescannte Barcodes (" + barcodes.size() + "):")); - - // Display all scanned barcodes - for (int i = 0; i < barcodes.size(); i++) { - Barcode barcode = barcodes.get(i); - String barcodeValue = barcode.getBarcode(); - - if (barcodeValue != null && !barcodeValue.isBlank()) { - // Create a styled container for each barcode - Div barcodeContainer = new Div(); - barcodeContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-s)") - .set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0") - .set("background-color", "var(--lumo-contrast-5pct)") - .set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)") - .set("word-break", "break-all"); - - Span barcodeSpan = new Span((i + 1) + ". " + barcodeValue); - barcodeContainer.add(barcodeSpan); - content.add(barcodeContainer); - } - } - } - } catch (Exception e) { - log.debug("Failed to load barcodes for task {}: {}", task.getId(), e.getMessage()); - } - } - } else if (task instanceof CommentTask commentTask) { - content.add(new Span("Kommentar erforderlich")); - - if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { - content.add(new Span("Hinweis: " + commentTask.getCommentText())); - } - - if (commentTask.isRequired()) { - content.add(new Span("Pflichtfeld")); - } - - // Show comments if task is completed - if (task.isCompleted()) { - try { - ObjectId taskId = new ObjectId(task.getIdAsString()); - List comments = commentRepository.findByTaskIdOrderByCreatedAtDesc(taskId); - - if (!comments.isEmpty()) { - content.add(new Span("Abgegebene Kommentare (" + comments.size() + "):")); - - for (Comment comment : comments) { - Div commentContainer = new Div(); - commentContainer.getStyle().set("background-color", "#f5f5f5") - .set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px") - .set("margin", "4px 0").set("font-family", "monospace") - .set("white-space", "pre-wrap"); - - Span commentText = new Span(comment.getCommentText()); - commentContainer.add(commentText); - content.add(commentContainer); - } - } - } catch (Exception e) { - log.debug("Failed to load comments for task {}: {}", task.getId(), e.getMessage()); - } - } - } - } - - private String formatDateTime(java.time.LocalDateTime dateTime) { - return DateTimeFormatUtil.formatDateTime(dateTime); - } - - private Div createTaskCard(BaseTask task, String displayName) { - Div taskCard = new Div(); - - // Card styling with fixed width - taskCard.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") - .set("margin", "var(--lumo-space-xs) 0").set("background-color", "var(--lumo-base-color)") - .set("cursor", "pointer").set("transition", "all 0.2s ease") - .set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)").set("display", "flex").set("align-items", "center") - .set("gap", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); - - // Hover effects - taskCard.getElement().addEventListener("mouseenter", e -> { - taskCard.getStyle().set("transform", "translateY(-2px)").set("box-shadow", "0 4px 12px rgba(0, 0, 0, 0.15)") - .set("border-color", "var(--lumo-primary-color-50pct)"); - }); - - taskCard.getElement().addEventListener("mouseleave", e -> { - taskCard.getStyle().set("transform", "translateY(0)").set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)") - .set("border-color", "var(--lumo-contrast-20pct)"); - }); - - // Task icon based on type - Icon taskIcon = getTaskIcon(task); - taskIcon.getStyle().set("color", - task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-primary-color)"); - - // Task content - VerticalLayout taskContent = new VerticalLayout(); - taskContent.setPadding(false); - taskContent.setSpacing(false); - taskContent.getStyle().set("flex-grow", "1"); - - // Task name with order number (display as 1-based instead of 0-based) - String taskNameWithOrder = (task.getTaskOrder() != null ? (task.getTaskOrder() + 1) + ". " : "") + displayName; - Span taskName = new Span(taskNameWithOrder); - taskName.getStyle().set("font-weight", "500").set("font-size", "var(--lumo-font-size-m)").set("color", - task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)"); - - // Task status/description - Span taskDescription = new Span(getTaskDescription(task)); - taskDescription.getStyle().set("font-size", "var(--lumo-font-size-s)") - .set("color", "var(--lumo-secondary-text-color)").set("margin-top", "var(--lumo-space-xs)"); - - taskContent.add(taskName, taskDescription); - - // Status indicator - Div statusIndicator = new Div(); - statusIndicator.getStyle().set("width", "8px").set("height", "8px").set("border-radius", "50%") - .set("background-color", task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-error-color)"); - - taskCard.add(taskIcon, taskContent, statusIndicator); - - // Click handler with hover state reset - taskCard.addClickListener(event -> { - showTaskDetailsDialog(task); - // Reset hover state after dialog interaction - taskCard.getStyle().set("transform", "translateY(0)").set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)") - .set("border-color", "var(--lumo-contrast-20pct)"); - }); - - return taskCard; - } - - private Icon getTaskIcon(BaseTask task) { - if (task instanceof TodoListTask) { - return new Icon(VaadinIcon.LIST); - } else if (task instanceof PhotoTask) { - return new Icon(VaadinIcon.CAMERA); - } else if (task instanceof SignatureTask) { - return new Icon(VaadinIcon.EDIT); - } else if (task instanceof ConfirmationTask) { - return new Icon(VaadinIcon.CHECK_CIRCLE); - } else if (task instanceof BarcodeTask) { - return new Icon(VaadinIcon.BARCODE); - } else if (task instanceof CommentTask) { - return new Icon(VaadinIcon.COMMENT); - } else { - return new Icon(VaadinIcon.TASKS); - } - } - - private String getTaskDescription(BaseTask task) { - if (task.isCompleted()) { - return "Abgeschlossen" - + (task.getCompletedAt() != null ? " am " + formatLocalDate(task.getCompletedAt().toLocalDate()) - : ""); - } - - if (task instanceof TodoListTask todoTask) { - int itemCount = todoTask.getTodoItems() != null ? todoTask.getTodoItems().size() : 0; - return itemCount + " Aufgabe" + (itemCount != 1 ? "n" : "") + " zu erledigen"; - } else if (task instanceof PhotoTask photoTask) { - if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { - return photoTask.getMinPhotoCount() + "-" + photoTask.getMaxPhotoCount() + " Fotos erforderlich"; - } else if (photoTask.getMinPhotoCount() != null) { - return "Mind. " + photoTask.getMinPhotoCount() + " Foto" - + (photoTask.getMinPhotoCount() != 1 ? "s" : ""); - } else if (photoTask.getMaxPhotoCount() != null) { - return "Max. " + photoTask.getMaxPhotoCount() + " Foto" - + (photoTask.getMaxPhotoCount() != 1 ? "s" : ""); - } else { - return "Foto erforderlich"; - } - } else if (task instanceof SignatureTask) { - return "Unterschrift erforderlich"; - } else if (task instanceof ConfirmationTask confirmationTask) { - if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { - return "Bestätigung: " + confirmationTask.getButtonText(); - } else { - return "Bestätigung erforderlich"; - } - } else if (task instanceof BarcodeTask) { - return "Barcode-Scan erforderlich"; - } else if (task instanceof CommentTask commentTask) { - if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { - return "Kommentar: " + commentTask.getCommentText(); - } else { - return "Kommentar erforderlich"; - } - } - - return "Aufgabe offen"; - } - - private Div createPhotoGallery(List photos) { - Div galleryContainer = new Div(); - galleryContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") - .set("background-color", "white").set("max-width", "600px").set("min-height", "500px") - .set("height", "500px").set("position", "relative").set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - - if (photos.size() == 1) { - // Single photo - no navigation needed - Div photoContainer = createPhotoContainer(photos.get(0)); - photoContainer.getStyle().set("flex", "1").set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - galleryContainer.add(photoContainer); - } else { - // Multiple photos - add navigation - final int[] currentIndex = { 0 }; // Use array to make it effectively final - - // Photo counter - Span photoCounter = new Span((currentIndex[0] + 1) + " / " + photos.size()); - photoCounter.getStyle().set("position", "absolute").set("top", "var(--lumo-space-s)") - .set("right", "var(--lumo-space-s)").set("background-color", "rgba(0, 0, 0, 0.6)") - .set("color", "white").set("padding", "var(--lumo-space-xs) var(--lumo-space-s)") - .set("border-radius", "var(--lumo-border-radius-s)").set("font-size", "var(--lumo-font-size-s)") - .set("z-index", "10"); - - // Photo container - Div photoContainer = createPhotoContainer(photos.get(0)); - photoContainer.getStyle().set("margin", "0 40px") // Space for buttons - .set("flex", "1").set("display", "flex").set("align-items", "center") - .set("justify-content", "center"); - - // Previous button - Button prevButton = new Button(new Icon(VaadinIcon.CHEVRON_LEFT)); - prevButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); - prevButton.getStyle().set("position", "absolute").set("left", "var(--lumo-space-s)").set("top", "50%") - .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") - .set("border-radius", "50%").set("z-index", "10"); - - // Next button - Button nextButton = new Button(new Icon(VaadinIcon.CHEVRON_RIGHT)); - nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); - nextButton.getStyle().set("position", "absolute").set("right", "var(--lumo-space-s)").set("top", "50%") - .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") - .set("border-radius", "50%").set("z-index", "10"); - - // Navigation logic - prevButton.addClickListener(e -> { - if (currentIndex[0] > 0) { - currentIndex[0]--; - updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, - photos.size()); - } - prevButton.setEnabled(currentIndex[0] > 0); - nextButton.setEnabled(currentIndex[0] < photos.size() - 1); - }); - - nextButton.addClickListener(e -> { - if (currentIndex[0] < photos.size() - 1) { - currentIndex[0]++; - updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, - photos.size()); - } - prevButton.setEnabled(currentIndex[0] > 0); - nextButton.setEnabled(currentIndex[0] < photos.size() - 1); - }); - - // Initial button states - prevButton.setEnabled(false); - nextButton.setEnabled(photos.size() > 1); - - galleryContainer.add(photoCounter, photoContainer, prevButton, nextButton); - } - - return galleryContainer; - } - - private Div createPhotoContainer(String base64Photo) { - Div photoContainer = new Div(); - photoContainer.getStyle().set("width", "100%").set("height", "100%").set("display", "flex") - .set("align-items", "center").set("justify-content", "center").set("overflow", "hidden"); - - // Create image element - String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; - - photoContainer.getElement().setProperty("innerHTML", ""); - - return photoContainer; - } - - private void updatePhotoDisplay(Div photoContainer, String base64Photo, Span counter, int current, int total) { - String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; - - photoContainer.getElement().setProperty("innerHTML", ""); - - counter.setText(current + " / " + total); - } - - private String makeResponsiveSvg(String svgContent) { - if (svgContent == null || svgContent.isBlank()) { - return svgContent; - } - - // Remove any existing width and height attributes and add responsive styling - String responsiveSvg = svgContent.replaceAll("width\\s*=\\s*[\"'][^\"']*[\"']", "") - .replaceAll("height\\s*=\\s*[\"'][^\"']*[\"']", "").replaceAll("style\\s*=\\s*[\"'][^\"']*[\"']", ""); - - // Add responsive styling - preserve viewBox if it exists, otherwise try to - // extract from width/height - if (!responsiveSvg.contains("viewBox")) { - // Try to extract original dimensions for viewBox - String widthMatch = extractAttribute(svgContent, "width"); - String heightMatch = extractAttribute(svgContent, "height"); - - if (widthMatch != null && heightMatch != null) { - try { - // Clean numbers (remove px, pt, etc.) - String cleanWidth = widthMatch.replaceAll("[^0-9.]", ""); - String cleanHeight = heightMatch.replaceAll("[^0-9.]", ""); - - if (!cleanWidth.isEmpty() && !cleanHeight.isEmpty()) { - responsiveSvg = responsiveSvg.replaceFirst(" taskCards = new ArrayList<>(); + + public JobSummaryView(JobRepository jobRepository, CargoItemRepository cargoItemRepository, + TaskRepository taskRepository, SignatureRepository signatureRepository, BarcodeRepository barcodeRepository, + PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, + MessageService messageService, JobHistoryService jobHistoryService, LocationService locationService) { + this.jobRepository = jobRepository; + this.cargoItemRepository = cargoItemRepository; + this.taskRepository = taskRepository; + this.signatureRepository = signatureRepository; + this.barcodeRepository = barcodeRepository; + this.photoRepository = photoRepository; + this.commentRepository = commentRepository; + this.appUserService = appUserService; + this.jobHistoryService = jobHistoryService; + this.locationService = locationService; + + setSizeFull(); + addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, + LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL); + + content = new VerticalLayout(); + content.setSpacing(true); + content.setPadding(true); + content.setWidthFull(); + } + + @Override + public void setParameter(BeforeEvent event, String parameter) { + content.removeAll(); + removeAll(); // Remove existing toolbar + + if (parameter == null || parameter.isBlank()) { + add(new ViewToolbar("Zusammenfassung")); + content.add(new Span("Fehler: Keine Job-ID angegeben")); + add(content); + return; + } + + ObjectId jobId; + try { + jobId = new ObjectId(parameter); + } catch (Exception e) { + add(new ViewToolbar("Zusammenfassung")); + content.add(new Span("Fehler: Ungültige Job-ID Format: " + parameter)); + add(content); + return; + } + + Job job = jobRepository.findById(jobId).orElse(null); + if (job == null) { + add(new ViewToolbar("Zusammenfassung")); + content.add(new Span("Fehler: Job mit ID " + parameter + " nicht gefunden")); + add(content); + return; + } + + // Create Send Message Button for toolbar + Button sendMessageButton = new Button("Nachricht senden"); + sendMessageButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + sendMessageButton.addClickListener(e -> { + // Check if job has an app user assigned + if (job.getAppUser() == null || job.getAppUser().isBlank()) { + Notification.show("Diesem Auftrag ist kein App-Nutzer zugeordnet", 3000, Notification.Position.MIDDLE) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + return; + } + + String appUserId = job.getAppUser(); + String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : job.getId().toHexString(); + + // Navigate to message details view with job conversation + // Format: message-details/{clientId}/job-{jobNumber} + String conversationId = "job-" + jobNumber; + getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId)); + }); + + // Create Job History Button for toolbar + Button jobHistoryButton = new Button("Job Historie"); + jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + jobHistoryButton.addClickListener(e -> { + getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString())); + }); + + // Add toolbar with both buttons in top right (Send Message button on the left) + add(new ViewToolbar("Zusammenfassung", sendMessageButton, jobHistoryButton)); + + List cargo = cargoItemRepository.findByJobId(jobId); + List tasks = taskRepository.findByJobIdOrderByTaskOrderAsc(jobId); + + render(job, cargo, tasks); + add(content); + } + + private void render(Job job, List cargoItems, List tasks) { + content.removeAll(); + + // Kopfzeile: Abholung/Lieferung + HorizontalLayout topRow = new HorizontalLayout(); + topRow.setWidthFull(); + topRow.setSpacing(true); + + VerticalLayout pickupBox = borderedBox(); + pickupBox.add(new H3("Abholung " + formatDateWithTime(job.getPickupDate(), job.getPickupTime()))); + pickupBox.add(new Span(valueOrEmpty(job.getPickupCompany()))); + pickupBox.add(new Span(valueOrEmpty(job.getPickupSalutation()) + (job.getPickupSalutation() != null ? " " : "") + + valueOrEmpty(job.getPickupFirstName()) + (job.getPickupFirstName() != null ? " " : "") + + valueOrEmpty(job.getPickupLastName()))); + pickupBox.add(new Span(concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()))); + pickupBox.add(new Span(concatZipCity(job.getPickupZip(), job.getPickupCity()))); + + VerticalLayout deliveryBox = borderedBox(); + deliveryBox.add(new H3("Lieferung " + formatDateWithTime(job.getDeliveryDate(), job.getDeliveryTime()))); + deliveryBox.add(new Span(valueOrEmpty(job.getDeliveryCompany()))); + deliveryBox.add(new Span(valueOrEmpty(job.getDeliverySalutation()) + + (job.getDeliverySalutation() != null ? " " : "") + valueOrEmpty(job.getDeliveryFirstName()) + + (job.getDeliveryFirstName() != null ? " " : "") + valueOrEmpty(job.getDeliveryLastName()))); + deliveryBox.add(new Span(concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()))); + deliveryBox.add(new Span(concatZipCity(job.getDeliveryZip(), job.getDeliveryCity()))); + + pickupBox.setWidth("50%"); + deliveryBox.setWidth("50%"); + topRow.add(pickupBox, deliveryBox); + content.add(topRow); + + // Aufgaben + VerticalLayout tasksBox = borderedBox(); + tasksBox.add(new H3("Zu quittierende Aufgaben")); + + // Ensure consistent spacing and width for task cards + tasksBox.setSpacing(false); + tasksBox.getStyle().set("gap", "var(--lumo-space-xs)"); + + // Clear previous task cards + taskCards.clear(); + + if (tasks == null || tasks.isEmpty()) { + tasksBox.add(new Span("Keine Aufgaben")); + } else { + for (BaseTask task : tasks) { + if (task != null) { + // Use getDisplayName() instead of getText() for task display + String displayName = task.getDisplayName(); + if (displayName != null && !displayName.isBlank()) { + Div taskCard = createTaskCard(task, displayName); + taskCards.add(taskCard); // Keep reference for hover reset + tasksBox.add(taskCard); + } + } + } + } + content.add(tasksBox); + + // Fracht und weitere Infos + HorizontalLayout midRow = new HorizontalLayout(); + midRow.setWidthFull(); + midRow.setSpacing(true); + + VerticalLayout cargoBox = borderedBox(); + cargoBox.add(new H3("Zu transportierende Fracht")); + if (cargoItems == null || cargoItems.isEmpty()) { + cargoBox.add(new Span("Keine Frachtangaben")); + } else { + for (CargoItem ci : cargoItems) { + if (ci == null) + continue; + String desc = ci.getDescription(); + Integer qty = ci.getQuantity(); + String dims = dimString(ci); + String weight = ci.getWeightKg() != null ? ci.getWeightKg() + " kg" : ""; + String line = (qty != null ? qty + " x " : "") + (desc != null ? desc : "") + + (dims.isBlank() ? "" : " " + dims) + (weight.isBlank() ? "" : " " + weight); + if (!line.isBlank()) + cargoBox.add(new Span(line)); + } + } + + VerticalLayout infoBox = borderedBox(); + infoBox.add(new H3("Weitere Informationen")); + infoBox.add(new Span("Preis: " + (job.getPrice() != null ? formatPrice(job.getPrice()) : "-"))); + if (job.getRemark() != null && !job.getRemark().isBlank()) { + infoBox.add(new Span("Bemerkung: " + job.getRemark())); + } + if (job.isDigitalProcessing()) { + infoBox.add(new Span("Digitale Abwicklung per App: aktiviert")); + } + if (job.getAppUser() != null && !job.getAppUser().isBlank()) { + infoBox.add(new Span("App-Nutzer: " + resolveAppUserName(job.getAppUser()))); + } + + cargoBox.setWidth("50%"); + infoBox.setWidth("50%"); + midRow.add(cargoBox, infoBox); + content.add(midRow); + + // Google Maps Karte mit Route + addRouteMap(job); + + // Manual completion button for jobs without digital processing + if (!job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED + && job.getStatus() != JobStatus.CANCELLED) { + HorizontalLayout buttonRow = new HorizontalLayout(); + buttonRow.setWidthFull(); + buttonRow.setJustifyContentMode(HorizontalLayout.JustifyContentMode.CENTER); + buttonRow.getStyle().set("margin-top", "var(--lumo-space-l)"); + + Button completeButton = new Button("Auftrag manuell abschließen", new Icon(VaadinIcon.CHECK_CIRCLE)); + completeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); + completeButton.addClickListener(e -> { + ConfirmDialog dialog = new ConfirmDialog(); + dialog.setHeader("Auftrag abschließen"); + dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " manuell abschließen?"); + dialog.setCancelable(true); + dialog.setCancelText("Abbrechen"); + dialog.setConfirmText("Abschließen"); + dialog.setConfirmButtonTheme("primary"); + dialog.addConfirmListener(ev -> { + try { + JobStatus oldStatus = job.getStatus(); + job.setStatus(JobStatus.COMPLETED); + job.setUpdatedAt(LocalDateTime.now()); + jobRepository.save(job); + jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, "Manuell"); + Notification + .show("Auftrag " + job.getJobNumber() + " wurde abgeschlossen.", 3000, + Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_SUCCESS); + // Re-render the page + getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString())); + } catch (Exception ex) { + Notification + .show("Fehler beim Abschließen: " + ex.getMessage(), 5000, + Notification.Position.BOTTOM_END) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + }); + dialog.open(); + }); + + buttonRow.add(completeButton); + content.add(buttonRow); + } + } + + private VerticalLayout borderedBox() { + VerticalLayout box = new VerticalLayout(); + box.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); + box.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + box.getStyle().set("background-color", "var(--lumo-base-color)"); + box.setPadding(true); + box.setSpacing(false); + return box; + } + + private String formatLocalDate(java.time.LocalDate date) { + try { + return DateTimeFormatUtil.formatDate(date); + } catch (Exception e) { + return ""; + } + } + + private String formatLocalTime(java.time.LocalTime time) { + try { + return DateTimeFormatUtil.formatTime(time); + } catch (Exception e) { + return ""; + } + } + + private String formatDateWithTime(java.time.LocalDate date, java.time.LocalTime time) { + StringBuilder sb = new StringBuilder(); + if (date != null) { + sb.append(formatLocalDate(date)); + if (time != null) { + sb.append(", ").append(formatLocalTime(time)); + } + } + return sb.toString(); + } + + private String valueOrEmpty(String v) { + return v == null ? "" : v; + } + + private String concatAddress(String street, String house) { + String s = valueOrEmpty(street); + String h = valueOrEmpty(house); + return (s + (h.isBlank() ? "" : " " + h)).trim(); + } + + private String concatZipCity(String zip, String city) { + String z = valueOrEmpty(zip); + String c = valueOrEmpty(city); + if (!z.isBlank() && !c.isBlank()) + return z + " " + c; + return (z + " " + c).trim(); + } + + private String dimString(CargoItem ci) { + // Values are stored in cm (not mm), so display directly without division + String len = ci.getLengthMm() != null ? ci.getLengthMm().intValue() + " cm" : ""; + String wid = ci.getWidthMm() != null ? ci.getWidthMm().intValue() + " cm" : ""; + String hei = ci.getHeightMm() != null ? ci.getHeightMm().intValue() + " cm" : ""; + String combined = String.join(" x ", + java.util.stream.Stream.of(len, wid, hei).filter(s -> !s.isBlank()).toList()); + return combined.isBlank() ? "" : combined; + } + + private String formatPrice(java.math.BigDecimal price) { + java.text.NumberFormat nf = java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY); + return nf.format(price); + } + + private String resolveAppUserName(String appUserIdString) { + try { + ObjectId id = new ObjectId(appUserIdString); + AppUser au = appUserService.findById(id); + if (au != null) { + String fn = au.getVorname(); + String ln = au.getNachname(); + String name = (fn != null ? fn : "").trim() + (fn != null && ln != null ? " " : "") + + (ln != null ? ln : ""); + if (!name.isBlank()) + return name; + if (au.getBezeichnung() != null && !au.getBezeichnung().isBlank()) + return au.getBezeichnung(); + if (au.getEmail() != null && !au.getEmail().isBlank()) + return au.getEmail(); + } + } catch (Exception e) { + log.debug("Failed to resolve AppUser name for ID {}: {}", appUserIdString, e.getMessage()); + } + return appUserIdString; // Fallback: show raw string if lookup fails + } + + private void addRouteMap(Job job) { + // Baue Adress-Strings + String origin = (concatAddress(job.getPickupStreet(), job.getPickupHouseNumber()) + ", " + + concatZipCity(job.getPickupZip(), job.getPickupCity())).trim(); + String destination = (concatAddress(job.getDeliveryStreet(), job.getDeliveryHouseNumber()) + ", " + + concatZipCity(job.getDeliveryZip(), job.getDeliveryCity())).trim(); + + if (origin.isBlank() || destination.isBlank()) { + return; + } + + // Prüfe ob App-Tracking aktiviert ist und Job nicht erledigt/storniert + LocationPosition appUserPosition = null; + boolean showAppUserPosition = job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED + && job.getStatus() != JobStatus.CANCELLED && job.getAppUser() != null && !job.getAppUser().isBlank(); + + if (showAppUserPosition) { + appUserPosition = locationService.getLatestPosition(job.getAppUser()); + } + + Div map = new Div(); + map.setWidthFull(); + map.setHeight("520px"); + map.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); + map.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + + Div routeInfo = new Div(); + routeInfo.setWidthFull(); + routeInfo.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)"); + routeInfo.getStyle().set("border-radius", "var(--lumo-border-radius-m)"); + routeInfo.getStyle().set("padding", "var(--lumo-space-m)"); + routeInfo.getStyle().set("background-color", "var(--lumo-base-color)"); + + content.add(map, routeInfo); + + // Position für JavaScript vorbereiten + final LocationPosition position = appUserPosition; + final boolean hasPosition = position != null && position.getLatitude() != null + && position.getLongitude() != null; + final String appUserId = showAppUserPosition ? job.getAppUser() : ""; + final boolean shouldUpdate = showAppUserPosition; + + String js = buildMapJs(origin, destination, hasPosition, position, appUserId, shouldUpdate); + + map.getElement().executeJs(js, map.getElement(), routeInfo.getElement()); + } + + private String buildMapJs(String origin, String destination, boolean hasPosition, LocationPosition position, + String appUserId, boolean shouldUpdate) { + String apiKey = getGoogleMapsApiKey(); + // Explizit mit Punkt als Dezimaltrennzeichen formatieren + String lat = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLatitude()) : "0"; + String lng = hasPosition ? String.format(java.util.Locale.US, "%.6f", position.getLongitude()) : "0"; + + return """ + (function(){ + var host = $0; + var infoEl = $1; + var origin = '%s'; + var destination = '%s'; + var apiKey = '%s'; + var appUserLat = %s; + var appUserLng = %s; + var hasAppUserPos = %s; + var appUserId = '%s'; + var shouldUpdate = %s; + + var appUserMarker = null; + var updateInterval = null; + var map = null; + + function init(){ + map = new google.maps.Map(host, { + center: {lat: 51.163, lng: 10.447}, + zoom: 6, + mapTypeControl: false + }); + + var trafficLayer = new google.maps.TrafficLayer(); + trafficLayer.setMap(map); + + var ds = new google.maps.DirectionsService(); + ds.route({ + origin: origin, + destination: destination, + travelMode: google.maps.TravelMode.DRIVING, + provideRouteAlternatives: true, + drivingOptions: { + departureTime: new Date(), + trafficModel: google.maps.TrafficModel.BEST_GUESS + } + }, function(res, status){ + if(status === 'OK'){ + infoEl.innerHTML = ''; + var bounds = new google.maps.LatLngBounds(); + var renderers = []; + var polylines = []; + + res.routes.forEach(function(route, idx){ + var dr = new google.maps.DirectionsRenderer({ + map: map, + preserveViewport: idx > 0, + suppressMarkers: false, + suppressPolylines: true + }); + dr.setRouteIndex(idx); + dr.setDirections(res); + renderers.push(dr); + + var path = route.overview_path || []; + var poly = new google.maps.Polyline({ + path: path, + strokeColor: idx === 0 ? '#1976d2' : '#90caf9', + strokeOpacity: 0.95, + strokeWeight: idx === 0 ? 6 : 4 + }); + poly.setMap(map); + polylines.push(poly); + + var leg = route.legs && route.legs[0]; + if(leg){ + var dur = leg.duration ? leg.duration.text : ''; + var durT = leg.duration_in_traffic ? leg.duration_in_traffic.text : ''; + var dist = leg.distance ? leg.distance.text : ''; + var alt = (idx === 0 ? 'Schnellste Route' : 'Alternative ' + idx); + + var row = document.createElement('div'); + row.style.margin = '4px 0'; + row.style.cursor = 'pointer'; + row.textContent = alt + ': ' + dist + ' • Dauer: ' + dur + (durT ? ' (mit Verkehr: ' + durT + ')' : ''); + + row.onmouseenter = function(){ + polylines.forEach(function(p, i){ + p.setOptions({ + strokeColor: i === 0 ? '#90caf9' : '#e3f2fd', + strokeOpacity: 0.6, + strokeWeight: 3 + }); + }); + poly.setOptions({ + strokeColor: '#0d47a1', + strokeOpacity: 1, + strokeWeight: 7 + }); + }; + + row.onmouseleave = function(){ + polylines.forEach(function(p, i){ + p.setOptions({ + strokeColor: i === 0 ? '#1976d2' : '#90caf9', + strokeOpacity: 0.95, + strokeWeight: i === 0 ? 6 : 4 + }); + }); + }; + + infoEl.appendChild(row); + + if(path && path.length){ + path.forEach(function(pt){ bounds.extend(pt); }); + } + } + }); + + // App-Nutzer Position Marker + if(hasAppUserPos){ + createOrUpdateAppUserMarker(appUserLat, appUserLng); + bounds.extend({lat: appUserLat, lng: appUserLng}); + } + + if(!bounds.isEmpty()){ + map.fitBounds(bounds); + } + + // Alle 30 Sekunden aktualisieren + if(shouldUpdate && appUserId){ + startPositionUpdates(appUserId); + } + } + }); + } + + function createOrUpdateAppUserMarker(lat, lng){ + if(appUserMarker){ + appUserMarker.setPosition({lat: lat, lng: lng}); + } else { + appUserMarker = new google.maps.Marker({ + position: {lat: lat, lng: lng}, + map: map, + title: 'Position App-Nutzer', + icon: { + path: google.maps.SymbolPath.CIRCLE, + scale: 10, + fillColor: '#4caf50', + fillOpacity: 1, + strokeColor: '#ffffff', + strokeWeight: 2 + } + }); + + var infoWindow = new google.maps.InfoWindow({ + content: '
Position App-Nutzer
' + }); + + appUserMarker.addListener('click', function(){ + infoWindow.open(map, appUserMarker); + }); + } + } + + function startPositionUpdates(userId){ + updateInterval = setInterval(function(){ + fetch('/api/location/' + encodeURIComponent(userId)) + .then(function(response){ + if(!response.ok) throw new Error('No position'); + return response.json(); + }) + .then(function(data){ + if(data && data.latitude && data.longitude){ + createOrUpdateAppUserMarker(data.latitude, data.longitude); + } + }) + .catch(function(err){ + console.log('Location update failed:', err); + }); + }, 30000); + } + + if(!(window.google && window.google.maps)){ + var s = document.createElement('script'); + s.src = 'https://maps.googleapis.com/maps/api/js?key=' + apiKey + '&libraries=places'; + s.onload = init; + document.head.appendChild(s); + } else { + init(); + } + })(); + """ + .formatted(escapeJs(origin), escapeJs(destination), escapeJs(apiKey), lat, lng, + Boolean.toString(hasPosition), escapeJs(appUserId), Boolean.toString(shouldUpdate)); + } + + // Hilfsfunktion zum einfachen Escapen von JS-Zeichen in Strings + private String escapeJs(String s) { + if (s == null) + return ""; + return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", " ").replace("\r", " "); + } + + private void showTaskDetailsDialog(BaseTask task) { + Dialog dialog = new Dialog(); + dialog.setWidth("500px"); + dialog.setResizable(true); + dialog.setDraggable(true); + + // Reset all task card hover states when dialog closes + dialog.addDialogCloseActionListener(e -> resetAllTaskCardHoverStates()); + + VerticalLayout dialogContent = new VerticalLayout(); + dialogContent.setPadding(true); + dialogContent.setSpacing(true); + + // Header + H4 header = new H4("Aufgaben-Details"); + dialogContent.add(header); + + // Task type and status + Span typeSpan = new Span("Typ: " + task.getDisplayName()); + typeSpan.getStyle().set("font-weight", "bold"); + dialogContent.add(typeSpan); + + Span statusSpan = new Span("Status: " + (task.isCompleted() ? "Abgeschlossen" : "Offen")); + if (task.isCompleted()) { + statusSpan.getStyle().set("color", "var(--lumo-success-text-color)"); + } else { + statusSpan.getStyle().set("color", "var(--lumo-error-text-color)"); + } + dialogContent.add(statusSpan); + + // Task-specific details + addTaskSpecificDetails(dialogContent, task); + + // Completion details if completed + if (task.isCompleted()) { + dialogContent.add(new Span("")); // Spacer + if (task.getCompletedAt() != null) { + dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt()))); + } + if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) { + dialogContent.add(new Span("Abgeschlossen von: " + task.getCompletedBy())); + } + } + + // Close button + Button closeButton = new Button("Schließen", e -> { + dialog.close(); + resetAllTaskCardHoverStates(); + }); + closeButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + HorizontalLayout buttonLayout = new HorizontalLayout(closeButton); + buttonLayout.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END); + dialogContent.add(buttonLayout); + + dialog.add(dialogContent); + dialog.open(); + } + + private void addTaskSpecificDetails(VerticalLayout content, BaseTask task) { + if (task instanceof TodoListTask todoTask) { + content.add(new Span("To-Do Items:")); + if (todoTask.getTodoItems() != null && !todoTask.getTodoItems().isEmpty()) { + for (String item : todoTask.getTodoItems()) { + if (item != null && !item.isBlank()) { + Span itemSpan = new Span(" • " + item); + itemSpan.getStyle().set("margin-left", "20px"); + content.add(itemSpan); + } + } + } else { + content.add(new Span(" Keine Items definiert")); + } + } else if (task instanceof PhotoTask photoTask) { + if (photoTask.getMinPhotoCount() != null || photoTask.getMaxPhotoCount() != null) { + String photoInfo = "Fotos: "; + if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { + photoInfo += photoTask.getMinPhotoCount() + " - " + photoTask.getMaxPhotoCount() + + " Fotos erforderlich"; + } else if (photoTask.getMinPhotoCount() != null) { + photoInfo += "Mindestens " + photoTask.getMinPhotoCount() + " Fotos erforderlich"; + } else if (photoTask.getMaxPhotoCount() != null) { + photoInfo += "Maximal " + photoTask.getMaxPhotoCount() + " Fotos erlaubt"; + } + content.add(new Span(photoInfo)); + } + + // Show photos if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List photos = photoRepository.findByTaskId(taskId); + + if (!photos.isEmpty()) { + content.add(new Span("")); // Spacer + + // Collect all photos from all Photo entries + List allPhotos = new ArrayList<>(); + for (Photo photo : photos) { + if (photo.getPhoto() != null && !photo.getPhoto().isBlank()) { + allPhotos.add(photo.getPhoto()); + } + } + + if (!allPhotos.isEmpty()) { + content.add(new Span("Aufgenommene Fotos (" + allPhotos.size() + "):")); + + // Create photo gallery container + Div photoGallery = createPhotoGallery(allPhotos); + content.add(photoGallery); + } + } + } catch (Exception e) { + log.debug("Failed to load photos for task {}: {}", task.getId(), e.getMessage()); + } + } + } else if (task instanceof ConfirmationTask confirmationTask) { + if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { + content.add(new Span("Button-Text: " + confirmationTask.getButtonText())); + } + } else if (task instanceof SignatureTask) { + content.add(new Span("Unterschrift erforderlich")); + + // Show signature if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List signatures = signatureRepository.findByTaskId(taskId); + + if (!signatures.isEmpty()) { + content.add(new Span("")); // Spacer + content.add(new Span("Gespeicherte Unterschrift:")); + + // Display the latest signature (assuming one signature per task) + Signature signature = signatures.get(signatures.size() - 1); + String svgContent = signature.getSignatureSvg(); + + if (svgContent != null && !svgContent.isBlank()) { + // Create a div to hold the SVG + Div svgContainer = new Div(); + svgContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)") + .set("padding", "var(--lumo-space-s)").set("background-color", "white") + .set("width", "100%").set("max-width", "450px").set("overflow", "hidden") + .set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + + // Process SVG to make it responsive + String responsiveSvg = makeResponsiveSvg(svgContent); + svgContainer.getElement().setProperty("innerHTML", responsiveSvg); + content.add(svgContainer); + } + } + } catch (Exception e) { + log.debug("Failed to load signature for task {}: {}", task.getId(), e.getMessage()); + } + } + } else if (task instanceof BarcodeTask) { + content.add(new Span("Barcode-Scan erforderlich")); + + // Show barcodes if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List barcodes = barcodeRepository.findByTaskId(taskId); + + if (!barcodes.isEmpty()) { + content.add(new Span("")); // Spacer + content.add(new Span("Gescannte Barcodes (" + barcodes.size() + "):")); + + // Display all scanned barcodes + for (int i = 0; i < barcodes.size(); i++) { + Barcode barcode = barcodes.get(i); + String barcodeValue = barcode.getBarcode(); + + if (barcodeValue != null && !barcodeValue.isBlank()) { + // Create a styled container for each barcode + Div barcodeContainer = new Div(); + barcodeContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-s)") + .set("padding", "var(--lumo-space-s)").set("margin", "var(--lumo-space-xs) 0") + .set("background-color", "var(--lumo-contrast-5pct)") + .set("font-family", "monospace").set("font-size", "var(--lumo-font-size-s)") + .set("word-break", "break-all"); + + Span barcodeSpan = new Span((i + 1) + ". " + barcodeValue); + barcodeContainer.add(barcodeSpan); + content.add(barcodeContainer); + } + } + } + } catch (Exception e) { + log.debug("Failed to load barcodes for task {}: {}", task.getId(), e.getMessage()); + } + } + } else if (task instanceof CommentTask commentTask) { + content.add(new Span("Kommentar erforderlich")); + + if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { + content.add(new Span("Hinweis: " + commentTask.getCommentText())); + } + + if (commentTask.isRequired()) { + content.add(new Span("Pflichtfeld")); + } + + // Show comments if task is completed + if (task.isCompleted()) { + try { + ObjectId taskId = new ObjectId(task.getIdAsString()); + List comments = commentRepository.findByTaskIdOrderByCreatedAtDesc(taskId); + + if (!comments.isEmpty()) { + content.add(new Span("Abgegebene Kommentare (" + comments.size() + "):")); + + for (Comment comment : comments) { + Div commentContainer = new Div(); + commentContainer.getStyle().set("background-color", "#f5f5f5") + .set("border", "1px solid #ddd").set("border-radius", "4px").set("padding", "8px") + .set("margin", "4px 0").set("font-family", "monospace") + .set("white-space", "pre-wrap"); + + Span commentText = new Span(comment.getCommentText()); + commentContainer.add(commentText); + content.add(commentContainer); + } + } + } catch (Exception e) { + log.debug("Failed to load comments for task {}: {}", task.getId(), e.getMessage()); + } + } + } + } + + private String formatDateTime(java.time.LocalDateTime dateTime) { + return DateTimeFormatUtil.formatDateTime(dateTime); + } + + private Div createTaskCard(BaseTask task, String displayName) { + Div taskCard = new Div(); + + // Card styling with fixed width + taskCard.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") + .set("margin", "var(--lumo-space-xs) 0").set("background-color", "var(--lumo-base-color)") + .set("cursor", "pointer").set("transition", "all 0.2s ease") + .set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)").set("display", "flex").set("align-items", "center") + .set("gap", "var(--lumo-space-m)").set("width", "100%").set("box-sizing", "border-box"); + + // Hover effects + taskCard.getElement().addEventListener("mouseenter", e -> { + taskCard.getStyle().set("transform", "translateY(-2px)").set("box-shadow", "0 4px 12px rgba(0, 0, 0, 0.15)") + .set("border-color", "var(--lumo-primary-color-50pct)"); + }); + + taskCard.getElement().addEventListener("mouseleave", e -> { + taskCard.getStyle().set("transform", "translateY(0)").set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)") + .set("border-color", "var(--lumo-contrast-20pct)"); + }); + + // Task icon based on type + Icon taskIcon = getTaskIcon(task); + taskIcon.getStyle().set("color", + task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-primary-color)"); + + // Task content + VerticalLayout taskContent = new VerticalLayout(); + taskContent.setPadding(false); + taskContent.setSpacing(false); + taskContent.getStyle().set("flex-grow", "1"); + + // Task name with order number (display as 1-based instead of 0-based) + String taskNameWithOrder = (task.getTaskOrder() != null ? (task.getTaskOrder() + 1) + ". " : "") + displayName; + Span taskName = new Span(taskNameWithOrder); + taskName.getStyle().set("font-weight", "500").set("font-size", "var(--lumo-font-size-m)").set("color", + task.isCompleted() ? "var(--lumo-success-text-color)" : "var(--lumo-body-text-color)"); + + // Task status/description + Span taskDescription = new Span(getTaskDescription(task)); + taskDescription.getStyle().set("font-size", "var(--lumo-font-size-s)") + .set("color", "var(--lumo-secondary-text-color)").set("margin-top", "var(--lumo-space-xs)"); + + taskContent.add(taskName, taskDescription); + + // Status indicator + Div statusIndicator = new Div(); + statusIndicator.getStyle().set("width", "8px").set("height", "8px").set("border-radius", "50%") + .set("background-color", task.isCompleted() ? "var(--lumo-success-color)" : "var(--lumo-error-color)"); + + taskCard.add(taskIcon, taskContent, statusIndicator); + + // Click handler with hover state reset + taskCard.addClickListener(event -> { + showTaskDetailsDialog(task); + // Reset hover state after dialog interaction + taskCard.getStyle().set("transform", "translateY(0)").set("box-shadow", "0 1px 3px rgba(0, 0, 0, 0.1)") + .set("border-color", "var(--lumo-contrast-20pct)"); + }); + + return taskCard; + } + + private Icon getTaskIcon(BaseTask task) { + if (task instanceof TodoListTask) { + return new Icon(VaadinIcon.LIST); + } else if (task instanceof PhotoTask) { + return new Icon(VaadinIcon.CAMERA); + } else if (task instanceof SignatureTask) { + return new Icon(VaadinIcon.EDIT); + } else if (task instanceof ConfirmationTask) { + return new Icon(VaadinIcon.CHECK_CIRCLE); + } else if (task instanceof BarcodeTask) { + return new Icon(VaadinIcon.BARCODE); + } else if (task instanceof CommentTask) { + return new Icon(VaadinIcon.COMMENT); + } else { + return new Icon(VaadinIcon.TASKS); + } + } + + private String getTaskDescription(BaseTask task) { + if (task.isCompleted()) { + return "Abgeschlossen" + + (task.getCompletedAt() != null ? " am " + formatLocalDate(task.getCompletedAt().toLocalDate()) + : ""); + } + + if (task instanceof TodoListTask todoTask) { + int itemCount = todoTask.getTodoItems() != null ? todoTask.getTodoItems().size() : 0; + return itemCount + " Aufgabe" + (itemCount != 1 ? "n" : "") + " zu erledigen"; + } else if (task instanceof PhotoTask photoTask) { + if (photoTask.getMinPhotoCount() != null && photoTask.getMaxPhotoCount() != null) { + return photoTask.getMinPhotoCount() + "-" + photoTask.getMaxPhotoCount() + " Fotos erforderlich"; + } else if (photoTask.getMinPhotoCount() != null) { + return "Mind. " + photoTask.getMinPhotoCount() + " Foto" + + (photoTask.getMinPhotoCount() != 1 ? "s" : ""); + } else if (photoTask.getMaxPhotoCount() != null) { + return "Max. " + photoTask.getMaxPhotoCount() + " Foto" + + (photoTask.getMaxPhotoCount() != 1 ? "s" : ""); + } else { + return "Foto erforderlich"; + } + } else if (task instanceof SignatureTask) { + return "Unterschrift erforderlich"; + } else if (task instanceof ConfirmationTask confirmationTask) { + if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { + return "Bestätigung: " + confirmationTask.getButtonText(); + } else { + return "Bestätigung erforderlich"; + } + } else if (task instanceof BarcodeTask) { + return "Barcode-Scan erforderlich"; + } else if (task instanceof CommentTask commentTask) { + if (commentTask.getCommentText() != null && !commentTask.getCommentText().isBlank()) { + return "Kommentar: " + commentTask.getCommentText(); + } else { + return "Kommentar erforderlich"; + } + } + + return "Aufgabe offen"; + } + + private Div createPhotoGallery(List photos) { + Div galleryContainer = new Div(); + galleryContainer.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)") + .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)") + .set("background-color", "white").set("max-width", "600px").set("min-height", "500px") + .set("height", "500px").set("position", "relative").set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + + if (photos.size() == 1) { + // Single photo - no navigation needed + Div photoContainer = createPhotoContainer(photos.get(0)); + photoContainer.getStyle().set("flex", "1").set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + galleryContainer.add(photoContainer); + } else { + // Multiple photos - add navigation + final int[] currentIndex = { 0 }; // Use array to make it effectively final + + // Photo counter + Span photoCounter = new Span((currentIndex[0] + 1) + " / " + photos.size()); + photoCounter.getStyle().set("position", "absolute").set("top", "var(--lumo-space-s)") + .set("right", "var(--lumo-space-s)").set("background-color", "rgba(0, 0, 0, 0.6)") + .set("color", "white").set("padding", "var(--lumo-space-xs) var(--lumo-space-s)") + .set("border-radius", "var(--lumo-border-radius-s)").set("font-size", "var(--lumo-font-size-s)") + .set("z-index", "10"); + + // Photo container + Div photoContainer = createPhotoContainer(photos.get(0)); + photoContainer.getStyle().set("margin", "0 40px") // Space for buttons + .set("flex", "1").set("display", "flex").set("align-items", "center") + .set("justify-content", "center"); + + // Previous button + Button prevButton = new Button(new Icon(VaadinIcon.CHEVRON_LEFT)); + prevButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); + prevButton.getStyle().set("position", "absolute").set("left", "var(--lumo-space-s)").set("top", "50%") + .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") + .set("border-radius", "50%").set("z-index", "10"); + + // Next button + Button nextButton = new Button(new Icon(VaadinIcon.CHEVRON_RIGHT)); + nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ICON); + nextButton.getStyle().set("position", "absolute").set("right", "var(--lumo-space-s)").set("top", "50%") + .set("transform", "translateY(-50%)").set("background-color", "rgba(255, 255, 255, 0.8)") + .set("border-radius", "50%").set("z-index", "10"); + + // Navigation logic + prevButton.addClickListener(e -> { + if (currentIndex[0] > 0) { + currentIndex[0]--; + updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, + photos.size()); + } + prevButton.setEnabled(currentIndex[0] > 0); + nextButton.setEnabled(currentIndex[0] < photos.size() - 1); + }); + + nextButton.addClickListener(e -> { + if (currentIndex[0] < photos.size() - 1) { + currentIndex[0]++; + updatePhotoDisplay(photoContainer, photos.get(currentIndex[0]), photoCounter, currentIndex[0] + 1, + photos.size()); + } + prevButton.setEnabled(currentIndex[0] > 0); + nextButton.setEnabled(currentIndex[0] < photos.size() - 1); + }); + + // Initial button states + prevButton.setEnabled(false); + nextButton.setEnabled(photos.size() > 1); + + galleryContainer.add(photoCounter, photoContainer, prevButton, nextButton); + } + + return galleryContainer; + } + + private Div createPhotoContainer(String base64Photo) { + Div photoContainer = new Div(); + photoContainer.getStyle().set("width", "100%").set("height", "100%").set("display", "flex") + .set("align-items", "center").set("justify-content", "center").set("overflow", "hidden"); + + // Create image element + String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; + + photoContainer.getElement().setProperty("innerHTML", ""); + + return photoContainer; + } + + private void updatePhotoDisplay(Div photoContainer, String base64Photo, Span counter, int current, int total) { + String imgSrc = base64Photo.startsWith("data:") ? base64Photo : "data:image/jpeg;base64," + base64Photo; + + photoContainer.getElement().setProperty("innerHTML", ""); + + counter.setText(current + " / " + total); + } + + private String makeResponsiveSvg(String svgContent) { + if (svgContent == null || svgContent.isBlank()) { + return svgContent; + } + + // Remove any existing width and height attributes and add responsive styling + String responsiveSvg = svgContent.replaceAll("width\\s*=\\s*[\"'][^\"']*[\"']", "") + .replaceAll("height\\s*=\\s*[\"'][^\"']*[\"']", "").replaceAll("style\\s*=\\s*[\"'][^\"']*[\"']", ""); + + // Add responsive styling - preserve viewBox if it exists, otherwise try to + // extract from width/height + if (!responsiveSvg.contains("viewBox")) { + // Try to extract original dimensions for viewBox + String widthMatch = extractAttribute(svgContent, "width"); + String heightMatch = extractAttribute(svgContent, "height"); + + if (widthMatch != null && heightMatch != null) { + try { + // Clean numbers (remove px, pt, etc.) + String cleanWidth = widthMatch.replaceAll("[^0-9.]", ""); + String cleanHeight = heightMatch.replaceAll("[^0-9.]", ""); + + if (!cleanWidth.isEmpty() && !cleanHeight.isEmpty()) { + responsiveSvg = responsiveSvg.replaceFirst(" {" - + " const osc = ctx.createOscillator();" - + " const gain = ctx.createGain();" - + " osc.connect(gain);" - + " gain.connect(ctx.destination);" - + " osc.frequency.value = 800;" - + " osc.type = 'sine';" - + " gain.gain.setValueAtTime(0.3, ctx.currentTime);" + ui.getPage() + .executeJs("try {" + " const ctx = new (window.AudioContext || window.webkitAudioContext)();" + + " ctx.resume().then(() => {" + " const osc = ctx.createOscillator();" + + " const gain = ctx.createGain();" + " osc.connect(gain);" + + " gain.connect(ctx.destination);" + " osc.frequency.value = 800;" + + " osc.type = 'sine';" + " gain.gain.setValueAtTime(0.3, ctx.currentTime);" + " gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);" - + " osc.start(ctx.currentTime);" - + " osc.stop(ctx.currentTime + 0.5);" - + " });" + + " osc.start(ctx.currentTime);" + " osc.stop(ctx.currentTime + 0.5);" + " });" + "} catch(e) { console.warn('Notification sound failed:', e); }"); // Show notification - Notification notification = Notification.show( - "Neue Nachricht von " + senderName + ": " + preview, - 4000, + Notification notification = Notification.show("Neue Nachricht von " + senderName + ": " + preview, 4000, Notification.Position.TOP_END); notification.addThemeVariants(NotificationVariant.LUMO_PRIMARY); @@ -410,12 +397,8 @@ public class MessagesView extends Main { return "Unbekannt"; } List appUsers = cachedAppUsers != null ? cachedAppUsers : List.of(); - return appUsers.stream() - .filter(user -> clientId.equals(user.getIdAsString()) - || clientId.equals(user.getEmail()) - || clientId.equals(user.getAppCode())) - .findFirst() - .map(this::buildClientName) - .orElse(clientId); + return appUsers.stream().filter(user -> clientId.equals(user.getIdAsString()) + || clientId.equals(user.getEmail()) || clientId.equals(user.getAppCode())).findFirst() + .map(this::buildClientName).orElse(clientId); } } diff --git a/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java b/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java index 03cfabb..53a847a 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java @@ -107,7 +107,8 @@ public class ShowJobsView extends VerticalLayout { grid.addColumn(job -> extractCompanyName(job.getCustomerSelection())).setHeader("Auftraggeber") .setAutoWidth(true).setFlexGrow(1).setSortable(true); grid.addColumn(Job::getJobNumber).setHeader("Auftragsnummer").setAutoWidth(true).setSortable(true); - grid.addColumn(job -> DateTimeFormatUtil.formatDateTime(job.getCreatedAt())).setHeader("Auftragsdatum").setAutoWidth(true).setSortable(true); + grid.addColumn(job -> DateTimeFormatUtil.formatDateTime(job.getCreatedAt())).setHeader("Auftragsdatum") + .setAutoWidth(true).setSortable(true); grid.addColumn(Job::getDeliveryCity).setHeader("Zielort").setAutoWidth(true).setFlexGrow(1).setSortable(true); // Action column: manual completion for jobs without digital processing @@ -126,8 +127,27 @@ public class ShowJobsView extends VerticalLayout { return new com.vaadin.flow.component.html.Span(); }).setHeader("").setAutoWidth(true).setFlexGrow(0); + // Invoice column - only show for completed jobs + grid.addComponentColumn(job -> { + if (job.getStatus() == JobStatus.COMPLETED) { + Button invoiceBtn = new Button(new Icon(VaadinIcon.DOLLAR)); + invoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS); + invoiceBtn.setTooltipText("Rechnung erstellen"); + invoiceBtn.addClickListener(e -> { + e.getSource().getElement().getNode(); // prevent row click + getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())); + }); + return invoiceBtn; + } + return new com.vaadin.flow.component.html.Span(); + }).setHeader("").setWidth("60px").setFlexGrow(0); + // Delete column (last column, right side) grid.addComponentColumn(job -> { + if (job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED + || job.getStatus() == JobStatus.DELIVERED) { + return new com.vaadin.flow.component.html.Span(); // No delete button for completed jobs + } Button deleteBtn = new Button(new Icon(VaadinIcon.TRASH)); deleteBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR); deleteBtn.setTooltipText("Auftrag löschen"); @@ -187,7 +207,8 @@ public class ShowJobsView extends VerticalLayout { private void showDeleteJobDialog(Job job) { ConfirmDialog dialog = new ConfirmDialog(); dialog.setHeader("Auftrag löschen"); - dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + " wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."); + dialog.setText("Möchten Sie den Auftrag " + job.getJobNumber() + + " wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."); dialog.setCancelable(true); dialog.setCancelText("Abbrechen"); dialog.setConfirmText("Löschen"); @@ -224,11 +245,8 @@ public class ShowJobsView extends VerticalLayout { return; } - Map payload = Map.of( - "type", "job_deleted", - "jobId", job.getId().toHexString(), - "jobNumber", job.getJobNumber() != null ? job.getJobNumber() : "", - "deletedAt", LocalDateTime.now().toString()); + Map payload = Map.of("type", "job_deleted", "jobId", job.getId().toHexString(), "jobNumber", + job.getJobNumber() != null ? job.getJobNumber() : "", "deletedAt", LocalDateTime.now().toString()); log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload); messagingPublisher.publishAsJson(appUserId, "job_deleted", payload); diff --git a/src/main/java/de/assecutor/votianlt/pages/view/StartView.java b/src/main/java/de/assecutor/votianlt/pages/view/StartView.java index 3f3d1d6..4fe8a19 100644 --- a/src/main/java/de/assecutor/votianlt/pages/view/StartView.java +++ b/src/main/java/de/assecutor/votianlt/pages/view/StartView.java @@ -225,7 +225,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver { featuresGrid.setWidthFull(); featuresGrid.setSpacing(true); featuresGrid.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER); - featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.START); + featuresGrid.setDefaultVerticalComponentAlignment(FlexComponent.Alignment.STRETCH); // Feature Cards featuresGrid.add(createFeatureCard(VaadinIcon.COG, "Einrichtungsassistent", @@ -249,6 +249,7 @@ public class StartView extends VerticalLayout implements BeforeEnterObserver { card.getStyle().set("background-color", "var(--lumo-base-color)"); card.getStyle().set("box-shadow", "var(--lumo-box-shadow-xs)"); card.setWidth("300px"); + card.setHeightFull(); Icon icon = iconType.create(); icon.setSize("48px"); diff --git a/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.class b/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.class new file mode 100644 index 0000000000000000000000000000000000000000..07262b0b64c7e4b1ab1d476ea6dbfeff9342846c GIT binary patch literal 930 zcmbVL+e!m55S@6dZN1hT{y{}B;G3)nttbeUN{f9@yXhLuCQFj7)SvKweDDMOC~?wV zTfqv84@;P{$(flmXFfjP-T|Ni2PIe_a2j&TtmT8$X+uZa38ti@h9}wzgvPXyB=?q^ z&J-!Z5`nEpHeysV714J8kq=x6RtN;zMARlmsAy?EfUb_0Wu# z!1hq6usOam)^RCaPb&h)^_J)CTdk-YC*0=gZo=TGHK`$WLeiG7E+A0uibye+8cv{7 z{N;ITz6!Apx%?I&xWEL}yu_FZN#LxWl`xT^>wMc-RS_-%wT$Tw#YdIGOH4&zJp)nl zEUwQ@Fl)P=IH47jSXsN^)#AP8<7o01E(Q-ORS|*L9}Q=74<_^Z=gf;0^QQy+L@HgK zngPES67}2vW!4if7J<_#0eYOkB7V!b zL(8}aC0NC%jL`yAFs{NH`2ASV&kfj|{@+4;+vCWPy{N&?b?VOx?e7b)CfI6(!U`W8OMyW!;I49@jjU%IMO44K31;egF9=I$+ zORYCg4MqgaU8x%-Q)Lr*-nqhy1q=y~^;``D>{aDVaH&INkYJ_^foj zmld#aX{>G(e^t0X*5_WMZxja}6``)vA%i%aB|W$%;<60p`{R!oK$CYVl&b!#0dGR_7T;6Ma{(4#8_Dc=Ph@j?g~ z<}=C*H_A&>gK@Sd*cyUKwx>SXKVbUpeTLs>VGhb=n}-F?8GgxJgr#1EWmw^Rj5B@$ FJOd6bH}wDj literal 0 HcmV?d00001 diff --git a/src/main/java/de/assecutor/votianlt/repository/InvoiceTemplateRepository.java b/src/main/java/de/assecutor/votianlt/repository/InvoiceTemplateRepository.java index ee425b5..32f61b9 100644 --- a/src/main/java/de/assecutor/votianlt/repository/InvoiceTemplateRepository.java +++ b/src/main/java/de/assecutor/votianlt/repository/InvoiceTemplateRepository.java @@ -8,17 +8,17 @@ import java.util.Optional; @Repository public interface InvoiceTemplateRepository extends MongoRepository { - + /** * Find the invoice template for a specific user */ Optional findByUserId(String userId); - + /** * Check if a template exists for a user */ boolean existsByUserId(String userId); - + /** * Delete the template for a user */ diff --git a/src/main/java/de/assecutor/votianlt/repository/JobHistoryRepository.class b/src/main/java/de/assecutor/votianlt/repository/JobHistoryRepository.class new file mode 100644 index 0000000000000000000000000000000000000000..934ead4f3d1fcf1d373d3f18e1c3e845fbff4c85 GIT binary patch literal 1962 zcmcIlOH&g;5bog>K~O+cP=qLGLirdU<%OlR0%!phr3{EC50l*{E^cO0GqX@B%7Z8G z{u~ef0DqKaPZC4K4YfIN*sATGp6+kH>G}HC?;k$_pa4r5=wt9aKrW0yKekHqLuErD zBg-|`lnKdI=Ub}05gPYfYCAJ9z+n79910!@S>fB|1N3bMh8TECSGcL^P*(P}sN#{* zpLrmx;8i6nDkyj6e&bvxX&KD#hcZ~LQ_am_TL-9D>$_nUjTP0}IvStB{e00?C>tfY zt=G`Bif;zqQn7(3wqeAJ1leOSyc1TWu(3u4@6+#GDJIiWRRKmFjCe1o#@rd03l8$r zj(~y;+6P)idxR*H&-t-C+U7AW@xqJB7ty+~=#qL}7ZRBHcAoir;_tz~^sIfNj>28K z+GJuroB=%Dt-0Ju&GPkpGWSHrLX(Up3G@<@Iatg$ue$O0cC34BNe6nHBCIEL(+*t; zeU!GP39lqoN5fz;pU`A0|2caqH&cqGYxBL(gyjeeQYtHKs3g^U){ud;Lh~+0U8lD? zcqpQnw4m?!3xffpv}G`LoXxo^4Ux%Zm$MI_WNCHOS5&>FMGR)UH^kshKs z*|K)Vmb3!HVC*<|;N&p3oPGJ2aE*}!p~Y0d_85Hc=#-{Rdp$9CN}^NWUssiOk~y-Y zV(sJWFrvGdNt!T^oRpK=Ct@`gH7ck6{A74|G<+EJ(>F--ZI}jB1}=~_OjaL^kbUu# zJqly(^-HvV8OGc81lf~trEO1-x7j3kp1V#^>z0?fK4rI{*Cp=U)I&g~~XL88FkPHj$FH zLdAu>$rU5Mr))t3E*Yx2_7mQGnM&hu*nq+Y*(A0{eAixXZqSw*hoc5mxNvP52E?(JfqKMt$zRTOq!JyByYA37#XV#hDUg%=7=V7Nwg#o3Kvy-Qxbll2xtO^zL z?Bzf)?h~(K!0`rieWF4^4S1ToQ1B`qsE~}w1x-{T^X_QxNxvNlz zNrj<)ep}ysl_m|W2}%^jP8v`g(TSw4Z*!RCE8$$+tfYjhTA=DRrM?06B!yxfYCF6| zygC-Sic%#8bjZOV)Jdi8I|!5SCWZJWE3EY@3~EeaHz#9NoqBAOif*jDoG6*g6!BIB zZ&4|6P*+8Wqc~X_a*4Zpl=(`UXz|cj3*G)GUe7&FPYgJ%>vXW^QsHFdAYXim!La|| zGs&7Bt@=L45y$t0XY%n|s`~9ESr=qoCD_L2iS9A+bY+tXM!PHz zm8KDaiP*jx1m8)`ZjX8v34*l{1uR_`Y{`gZ^hKVW!AW_2t!YIuv;@dYokx9aqOr`{iOXip^SafVIv~>fvH~~Gfz>0 z1F`-V?`|)54^VgXo`<}RKi)^8k<4>Dd!%RdiST;;8Lz6i!LFidd$EQ3|Liv6 zw;v93c$UE-GY-)-Je=3?T+*lyz_ZXDp!}^N)4VUkjyQ6Y5jaGz-KP_!sgfu=X4nw_0(MsH^Z+!$J&MCPDe2efxQD!3&Ya0q`#a0C4aH_350 zhSG7A#^40XC*f2oPoR7n&ZKey<+CuE%G&?q%=uJCJ_qMh`wM7)5iWt=U@xP7EXjKT zKEeD2w7Y`3PbGW?ri80#^?4@RHI(~ag~;n<~0(s x@FoVlb&$af8rX0?*DN9>dG^xX!%il-a8C9sZ!Ets zUI|@zZsou@MbMBRemCA6-SrIZQ>C+y!(-vjyBWjQdh!UXcw*X+cPLhWS~!Tu!(;(l5R>;6JsS<+qO- z9JFcnOlAA>K!rt@AEaeFc+IeORB5kDd7_*mLH4yaUU+4+qx?)|@4U3qgp~7ppJR&m zJf2e6J9H^(brv#A-Sg04iK-S}P_<0`2h^zoI-9@1zHR-)+%=*at_Y&5LC*%gfp_EK U70ivRRlFj+hBdq%fAt3V2TzU>g8%>k literal 0 HcmV?d00001 diff --git a/src/main/java/de/assecutor/votianlt/repository/ServiceRepository.java b/src/main/java/de/assecutor/votianlt/repository/ServiceRepository.java new file mode 100644 index 0000000..9db0a49 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/repository/ServiceRepository.java @@ -0,0 +1,13 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.Service; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface ServiceRepository extends MongoRepository { + List findByUserId(String userId); + + void deleteByUserId(String userId); +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java index 1724e80..7ef3219 100644 --- a/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java +++ b/src/main/java/de/assecutor/votianlt/security/SecurityConfig.java @@ -27,8 +27,7 @@ public class SecurityConfig extends VaadinWebSecurity { new AntPathRequestMatcher("/frontend/**"), new AntPathRequestMatcher("/webjars/**"), new AntPathRequestMatcher("/h2-console/**"), new AntPathRequestMatcher("/frontend-es5/**", "/frontend-es6/**"), - new AntPathRequestMatcher("/mcp/**"), - new AntPathRequestMatcher("/ws/**")) + new AntPathRequestMatcher("/mcp/**"), new AntPathRequestMatcher("/ws/**")) .permitAll()); // Standard-CSRF-Konfiguration diff --git a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index 429c86f..f20fa0e 100644 --- a/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -244,19 +244,20 @@ public class CustomerInvoiceService { } /** - * Generate a PDF preview from canvas template data. - * Creates an HTML representation of the canvas elements and converts it to PDF. + * Generate a PDF preview from canvas template data. Creates an HTML + * representation of the canvas elements and converts it to PDF. */ public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception { return generatePdfFromCanvasTemplate(jsonTemplateData, null); } - - public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user) throws Exception { + + public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user) + throws Exception { // Parse the JSON template data com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData); com.fasterxml.jackson.databind.JsonNode elements = rootNode.get("elements"); - + // Build HTML content from canvas elements StringBuilder htmlBuilder = new StringBuilder(); htmlBuilder.append(""); @@ -264,7 +265,8 @@ public class CustomerInvoiceService { htmlBuilder.append(""); htmlBuilder.append(""); htmlBuilder.append(""); - + // Prepare variable substitution map java.util.Map variables = new java.util.HashMap<>(); - + if (user != null) { // Use actual user data String company = user.getCompany(); @@ -294,7 +296,7 @@ public class CustomerInvoiceService { variables.put("masterdata.email", "kontakt@firma.de"); variables.put("masterdata.phone", "0123 456789"); } - + // Customer data (placeholder for now - would come from job/customer selection) variables.put("customer.company_name", "Kundenfirma GmbH"); variables.put("customer.contact_name", "Erika Mustermann"); @@ -302,7 +304,7 @@ public class CustomerInvoiceService { variables.put("customer.city", "54321 Kundenstadt"); variables.put("customer.email", "kunde@beispiel.de"); variables.put("customer.phone", "0987 654321"); - + if (elements != null && elements.isArray()) { for (com.fasterxml.jackson.databind.JsonNode element : elements) { String type = element.has("type") ? element.get("type").asText("text") : "text"; @@ -315,8 +317,9 @@ public class CustomerInvoiceService { } else { text = element.has("text") ? element.get("text").asText("") : ""; } - - // Use percentage values if available, otherwise fall back to legacy pixel values + + // Use percentage values if available, otherwise fall back to legacy pixel + // values double xPercent, yPercent, widthPercent, heightPercent; if (element.has("xPercent")) { xPercent = element.get("xPercent").asDouble(0); @@ -343,34 +346,37 @@ public class CustomerInvoiceService { double height = element.get("height").asDouble(30); heightPercent = height / 842.0 * 100; } - + int fontSize = element.has("fontSize") ? element.get("fontSize").asInt(14) : 14; String fontStyle = element.has("fontStyle") ? element.get("fontStyle").asText("") : ""; String color = element.has("color") ? element.get("color").asText("#333333") : "#333333"; - + // Convert percentages to mm (A4 is 210mm x 297mm) double mmX = xPercent / 100.0 * 210.0; double mmY = yPercent / 100.0 * 297.0; double mmWidth = widthPercent / 100.0 * 210.0; double mmHeight = heightPercent / 100.0 * 297.0; - + htmlBuilder.append("
"); - + // Replace variables with actual values FIRST if (variable != null && variables.containsKey(variable)) { text = variables.get(variable); @@ -390,20 +396,18 @@ public class CustomerInvoiceService { } } } - + // Escape HTML special characters in text AFTER variable replacement if (text != null) { - text = text.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'"); + text = text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + .replace("'", "'"); } else { text = ""; } - + if ("line".equals(type)) { - htmlBuilder.append("
"); + htmlBuilder.append( + "
"); } else if ("image".equals(type)) { if (element.has("imageData") && !element.get("imageData").asText().isEmpty()) { String imageData = element.get("imageData").asText(); @@ -412,27 +416,30 @@ public class CustomerInvoiceService { imageData = "data:image/png;base64," + imageData; } // Use object-fit:contain to preserve aspect ratio - htmlBuilder.append("
"); - htmlBuilder.append("Bild"); + htmlBuilder.append( + "
"); + htmlBuilder.append("Bild"); htmlBuilder.append("
"); } else { - htmlBuilder.append("
[Bild]
"); + htmlBuilder.append( + "
[Bild]
"); } } else { // Wrap text in a span to prevent flexbox issues htmlBuilder.append("").append(text).append(""); } - + htmlBuilder.append("
"); } } - + htmlBuilder.append(""); - + // Generate PDF from HTML return generatePdfFromHtmlString(htmlBuilder.toString()); } - + private String safe(String value) { return value != null ? value : ""; } diff --git a/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java b/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java index 4f9a6d7..46e9d0d 100644 --- a/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java +++ b/src/main/java/de/assecutor/votianlt/service/InvoiceTemplateService.java @@ -10,15 +10,15 @@ import java.util.Optional; @Service @RequiredArgsConstructor public class InvoiceTemplateService { - + private final InvoiceTemplateRepository invoiceTemplateRepository; - + /** * Save or update the invoice template for a user */ public InvoiceTemplate saveTemplate(String userId, String templateData) { Optional existing = invoiceTemplateRepository.findByUserId(userId); - + if (existing.isPresent()) { InvoiceTemplate template = existing.get(); template.updateTemplate(templateData); @@ -28,21 +28,21 @@ public class InvoiceTemplateService { return invoiceTemplateRepository.save(newTemplate); } } - + /** * Get the invoice template for a user */ public Optional getTemplateByUserId(String userId) { return invoiceTemplateRepository.findByUserId(userId); } - + /** * Check if a template exists for a user */ public boolean hasTemplate(String userId) { return invoiceTemplateRepository.existsByUserId(userId); } - + /** * Delete the template for a user */ diff --git a/src/main/java/de/assecutor/votianlt/service/LocationService.java b/src/main/java/de/assecutor/votianlt/service/LocationService.java index 2454750..de4f335 100644 --- a/src/main/java/de/assecutor/votianlt/service/LocationService.java +++ b/src/main/java/de/assecutor/votianlt/service/LocationService.java @@ -49,8 +49,8 @@ public class LocationService { Double heading = extractDouble(payload.get("heading")); Instant timestamp = extractInstant(payload.get("timestamp")); - LocationPosition position = new LocationPosition( - appUserId, latitude, longitude, accuracy, altitude, speed, heading, timestamp); + LocationPosition position = new LocationPosition(appUserId, latitude, longitude, accuracy, altitude, speed, + heading, timestamp); locationPositionRepository.save(position); log.debug("[Location] Saved position for {}: lat={}, lon={}", appUserId, latitude, longitude); @@ -68,7 +68,8 @@ public class LocationService { * @return The latest position or null if none found */ public LocationPosition getLatestPosition(String appUserId) { - List positions = locationPositionRepository.findTop1ByAppUserIdOrderByTimestampDesc(appUserId); + List positions = locationPositionRepository + .findTop1ByAppUserIdOrderByTimestampDesc(appUserId); return positions.isEmpty() ? null : positions.get(0); } @@ -87,9 +88,9 @@ public class LocationService { } /** - * Cleanup old positions. Runs every 5 minutes. - * Note: Positions also have a TTL index that auto-deletes after 60 minutes, - * but this scheduled cleanup ensures immediate removal and logging. + * Cleanup old positions. Runs every 5 minutes. Note: Positions also have a TTL + * index that auto-deletes after 60 minutes, but this scheduled cleanup ensures + * immediate removal and logging. */ @Scheduled(fixedRate = 300000) // 5 minutes public void cleanupOldPositions() { diff --git a/src/main/java/de/assecutor/votianlt/service/MessageService.java b/src/main/java/de/assecutor/votianlt/service/MessageService.java index 4dd2924..3d0110d 100644 --- a/src/main/java/de/assecutor/votianlt/service/MessageService.java +++ b/src/main/java/de/assecutor/votianlt/service/MessageService.java @@ -115,8 +115,8 @@ public class MessageService { } /** - * Publish message to topic for the receiver. - * Only sends if client is connected, otherwise keeps NOTSEND status. + * Publish message to topic for the receiver. Only sends if client is connected, + * otherwise keeps NOTSEND status. */ private void publishMessage(Message message, String receiver) { try { @@ -132,20 +132,18 @@ public class MessageService { byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8); // Use WebSocketService directly to get CompletableFuture for delivery tracking - webSocketService.sendToClient(receiver, "message", data) - .thenRun(() -> { - // Success: mark as sent - message.markAsSent(); - messageRepository.save(message); - log.debug("[Messaging] Message {} delivered to client {}, marked as SEND", - message.getIdAsString(), receiver); - }) - .exceptionally(ex -> { - // Failed to deliver: keep NOTSEND status - log.debug("[Messaging] Failed to deliver message {} to client {}: {}", - message.getIdAsString(), receiver, ex.getMessage()); - return null; - }); + webSocketService.sendToClient(receiver, "message", data).thenRun(() -> { + // Success: mark as sent + message.markAsSent(); + messageRepository.save(message); + log.debug("[Messaging] Message {} delivered to client {}, marked as SEND", message.getIdAsString(), + receiver); + }).exceptionally(ex -> { + // Failed to deliver: keep NOTSEND status + log.debug("[Messaging] Failed to deliver message {} to client {}: {}", message.getIdAsString(), + receiver, ex.getMessage()); + return null; + }); } catch (Exception e) { log.error("[Messaging] Error publishing message: {}", e.getMessage()); @@ -160,8 +158,8 @@ public class MessageService { } /** - * Send pending messages to a client that just connected. - * Called after successful authentication. + * Send pending messages to a client that just connected. Called after + * successful authentication. * * @param receiver * AppUser ID (clientId) @@ -184,16 +182,14 @@ public class MessageService { ChatMessageOutboundPayload payload = ChatMessageOutboundPayload.fromMessage(message); byte[] data = objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8); - webSocketService.sendToClient(receiver, "message", data) - .thenRun(() -> { - message.markAsSent(); - messageRepository.save(message); - }) - .exceptionally(ex -> { - log.error("[Messaging] Failed to send pending message {}: {}", - message.getIdAsString(), ex.getMessage()); - return null; - }); + webSocketService.sendToClient(receiver, "message", data).thenRun(() -> { + message.markAsSent(); + messageRepository.save(message); + }).exceptionally(ex -> { + log.error("[Messaging] Failed to send pending message {}: {}", message.getIdAsString(), + ex.getMessage()); + return null; + }); sentCount++; } catch (Exception e) { log.error("[Messaging] Failed to send pending message {}: {}", message.getIdAsString(), e.getMessage());