From 2e17e2ecb0a2b51bc7fcb02848e787482fe867c4 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 16 Oct 2026 18:53:05 +0000 Subject: [PATCH] Upload via GUI (69 Dateien) --- desktop/installer/Payload.cs | 106 +++ desktop/installer/Shortcut.cs | 110 +++ desktop/installer/Themes/Colors.Dark.xaml | 27 + desktop/installer/Themes/Colors.Light.xaml | 27 + desktop/installer/Themes/Controls.xaml | 651 +++++++++++++ .../obj/Release/net48/MainWindow.baml | Bin 0 -> 12060 bytes .../obj/Release/net48/MainWindow.g.cs | 842 +++++++++++++++++ .../obj/Release/net48/Themes/Colors.Dark.baml | Bin 0 -> 1917 bytes .../Release/net48/Themes/Colors.Light.baml | Bin 0 -> 1917 bytes .../obj/Release/net48/Themes/Controls.baml | Bin 0 -> 13030 bytes desktop/scripts/build-setup.js | 94 ++ desktop/src/admin-extra.js | 184 ++++ desktop/src/app.js | 876 ++++++++++++++++++ desktop/src/assets/icon.ico | Bin 0 -> 7162 bytes desktop/src/assets/logo.png | Bin 0 -> 7140 bytes desktop/src/index.html | 17 + desktop/src/main.js | 185 ++++ desktop/src/preload.js | 14 + desktop/src/styles.css | 222 +++++ 19 files changed, 3355 insertions(+) create mode 100644 desktop/installer/Payload.cs create mode 100644 desktop/installer/Shortcut.cs create mode 100644 desktop/installer/Themes/Colors.Dark.xaml create mode 100644 desktop/installer/Themes/Colors.Light.xaml create mode 100644 desktop/installer/Themes/Controls.xaml create mode 100644 desktop/installer/obj/Release/net48/MainWindow.baml create mode 100644 desktop/installer/obj/Release/net48/MainWindow.g.cs create mode 100644 desktop/installer/obj/Release/net48/Themes/Colors.Dark.baml create mode 100644 desktop/installer/obj/Release/net48/Themes/Colors.Light.baml create mode 100644 desktop/installer/obj/Release/net48/Themes/Controls.baml create mode 100644 desktop/scripts/build-setup.js create mode 100644 desktop/src/admin-extra.js create mode 100644 desktop/src/app.js create mode 100644 desktop/src/assets/icon.ico create mode 100644 desktop/src/assets/logo.png create mode 100644 desktop/src/index.html create mode 100644 desktop/src/main.js create mode 100644 desktop/src/preload.js create mode 100644 desktop/src/styles.css diff --git a/desktop/installer/Payload.cs b/desktop/installer/Payload.cs new file mode 100644 index 00000000..e1b885ab --- /dev/null +++ b/desktop/installer/Payload.cs @@ -0,0 +1,106 @@ +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace DeskHubSetup; + +/// +/// Die App liegt als ZIP hinten an der Setup-.exe angehängt: +/// [Setup.exe][ZIP][8 Byte: ZIP-Länge][8 Byte: "DESKHUBP"] +/// Windows ignoriert angehängte Daten beim Starten einer .exe. +/// +public static class Payload +{ + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("DESKHUBP"); + private const int FooterSize = 16; + + private static (long Offset, long Length)? _info; + private static bool _checked; + + /// Position und Länge des ZIPs in dieser .exe, oder null (z. B. beim Deinstallationsprogramm). + public static (long Offset, long Length)? Info + { + get + { + if (_checked) return _info; + _checked = true; + try + { + using var f = File.OpenRead(Installer.SelfPath); + if (f.Length < FooterSize) return null; + f.Seek(-FooterSize, SeekOrigin.End); + var footer = new byte[FooterSize]; + if (f.Read(footer, 0, FooterSize) != FooterSize) return null; + for (int i = 0; i < Magic.Length; i++) + if (footer[8 + i] != Magic[i]) return null; + long length = BitConverter.ToInt64(footer, 0); + long offset = f.Length - FooterSize - length; + if (length <= 0 || offset <= 0) return null; + _info = (offset, length); + } + catch { _info = null; } + return _info; + } + } + + public static bool Exists => Info is not null; + + /// Länge des eigentlichen Setup-Programms ohne Anhang. + public static long StubLength => Info?.Offset ?? new FileInfo(Installer.SelfPath).Length; + + public static ZipArchive OpenArchive() + { + var info = Info ?? throw new InvalidOperationException("Die Installationsdaten fehlen in dieser Datei. Bitte DeskHub-Setup.exe neu herunterladen."); + var file = File.OpenRead(Installer.SelfPath); + return new ZipArchive(new SubStream(file, info.Offset, info.Length), ZipArchiveMode.Read, leaveOpen: false); + } + + /// Nur-Lese-Ausschnitt einer Datei, damit ZipArchive den Anhang wie eine eigene Datei sieht. + private sealed class SubStream(Stream inner, long start, long length) : Stream + { + private long _pos; + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length => length; + + public override long Position + { + get => _pos; + set => _pos = Math.Max(0, Math.Min(value, length)); + } + + public override int Read(byte[] buffer, int offset, int count) + { + long left = length - _pos; + if (left <= 0) return 0; + if (count > left) count = (int)left; + inner.Position = start + _pos; + int n = inner.Read(buffer, offset, count); + _pos += n; + return n; + } + + public override long Seek(long offset, SeekOrigin origin) + { + Position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => _pos + offset, + _ => length + offset, + }; + return _pos; + } + + public override void Flush() { } + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) inner.Dispose(); + base.Dispose(disposing); + } + } +} diff --git a/desktop/installer/Shortcut.cs b/desktop/installer/Shortcut.cs new file mode 100644 index 00000000..e9447672 --- /dev/null +++ b/desktop/installer/Shortcut.cs @@ -0,0 +1,110 @@ +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; + +namespace DeskHubSetup; + +/// +/// Verknüpfungen (.lnk) über die Shell-Schnittstellen – inklusive AppUserModelID, damit +/// Taskleiste und Windows-Benachrichtigungen DeskHub wie bisher zuordnen (wie beim alten NSIS-Setup). +/// +public static class Shortcut +{ + public static void Create(string link, string target, string description, string appUserModelId) + { + Directory.CreateDirectory(Path.GetDirectoryName(link)!); + var shellLink = (IShellLinkW)new CShellLink(); + try + { + shellLink.SetPath(target); + shellLink.SetWorkingDirectory(Path.GetDirectoryName(target)!); + shellLink.SetIconLocation(target, 0); + shellLink.SetDescription(description); + + var store = (IPropertyStore)shellLink; + var key = new PropertyKey(new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), 5); // PKEY_AppUserModel_ID + var value = new PropVariant { vt = 31 /* VT_LPWSTR */, p = Marshal.StringToCoTaskMemUni(appUserModelId) }; + try + { + store.SetValue(ref key, ref value); + store.Commit(); + } + finally { Marshal.FreeCoTaskMem(value.p); } + + ((IPersistFile)shellLink).Save(link, true); + } + finally { Marshal.ReleaseComObject(shellLink); } + } + + /// Ziel einer vorhandenen Verknüpfung, oder null. + public static string? Target(string link) + { + if (!File.Exists(link)) return null; + var shellLink = (IShellLinkW)new CShellLink(); + try + { + ((IPersistFile)shellLink).Load(link, 0); + var sb = new StringBuilder(1024); + shellLink.GetPath(sb, sb.Capacity, IntPtr.Zero, 0); + return sb.ToString(); + } + catch { return null; } + finally { Marshal.ReleaseComObject(shellLink); } + } + + // ---------- COM ---------- + + [ComImport, Guid("00021401-0000-0000-C000-000000000046")] + private class CShellLink { } + + [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")] + private interface IShellLinkW + { + void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, uint fFlags); + void GetIDList(out IntPtr ppidl); + void SetIDList(IntPtr pidl); + void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName); + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath); + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath); + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + void GetHotkey(out short pwHotkey); + void SetHotkey(short wHotkey); + void GetShowCmd(out int piShowCmd); + void SetShowCmd(int iShowCmd); + void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon); + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved); + void Resolve(IntPtr hwnd, uint fFlags); + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); + } + + [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] + private interface IPropertyStore + { + void GetCount(out uint cProps); + void GetAt(uint iProp, out PropertyKey pkey); + void GetValue(ref PropertyKey key, out PropVariant pv); + void SetValue(ref PropertyKey key, ref PropVariant pv); + void Commit(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct PropertyKey(Guid fmtid, uint pid) + { + public Guid fmtid = fmtid; + public uint pid = pid; + } + + // PROPVARIANT: 16 Byte (32 Bit) bzw. 24 Byte (64 Bit) + [StructLayout(LayoutKind.Sequential)] + private struct PropVariant + { + public ushort vt; + public ushort r1, r2, r3; + public IntPtr p; + public IntPtr p2; + } +} diff --git a/desktop/installer/Themes/Colors.Dark.xaml b/desktop/installer/Themes/Colors.Dark.xaml new file mode 100644 index 00000000..f6250a89 --- /dev/null +++ b/desktop/installer/Themes/Colors.Dark.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + #000000 + diff --git a/desktop/installer/Themes/Colors.Light.xaml b/desktop/installer/Themes/Colors.Light.xaml new file mode 100644 index 00000000..e311b000 --- /dev/null +++ b/desktop/installer/Themes/Colors.Light.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + #000000 + diff --git a/desktop/installer/Themes/Controls.xaml b/desktop/installer/Themes/Controls.xaml new file mode 100644 index 00000000..9d6865af --- /dev/null +++ b/desktop/installer/Themes/Controls.xaml @@ -0,0 +1,651 @@ + + + + Segoe UI Variable Text, Segoe UI + Segoe UI Variable Display, Segoe UI + Segoe Fluent Icons, Segoe MDL2 Assets + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/installer/obj/Release/net48/MainWindow.baml b/desktop/installer/obj/Release/net48/MainWindow.baml new file mode 100644 index 0000000000000000000000000000000000000000..0057fcf4e2b1aa1642749043423dc5c36eeeb852 GIT binary patch literal 12060 zcmb_iYiu0Xb-v^-xo?uohn^I7B#vd--d%B*4^eK_gb&kFBgwKTTaMi{9da+p33q2U zGqaW@v_N7M1&aO%yf*5%a00|t6SzML^haHwwS(4dY8yo@yX_zMM^U&fnqU1P7X|8= zy8X_*Gy5WGDQ-wTX71d%_uO;O`ObIFog`!I0$XPDY!3gQ$K7u<{&q2eA2yn1EVanp z&9k+Y+%orS)gwpHhwb;*gJiU-7lVt43HtV@!dS$K;HH}=87X8Y0CecM5 z!sk|POHXyNh*7QP%~kv3J9_LL5jr(HaT8Y`iqLcsSDAD-^^RZWu6cuBuuEJ=OJc#W zE|`{CLE9Oj^)VJdZ(Qfjyi;>G#(yf_g0pBCJ9dRX=c&ejMPnD!;@zzG33CbdH~t@J z>1KNL-$i#}>V!CQoK&iHSn35{GL1Y9^H`NU&dnSQVH`F{O=J~@=b6^J6f6~Dkww$3 zmW}IYMCL%4jR}^1!sQUnu6dX3npHwqJsxJ^D^897gUF2b(Bejvku7_M0~k1OuSyfs z2YXm@0iMpC zg+Eaym8P5C$JW+d?rEtAi(N9-xwQLCdXT*YtG!}4>)g9?y~;EFL+qcnS@e=)SGnU| z*ZQL@;TsECVyw*3z5dGK|hgYk5?CMPfH}#^~G96 zOQl$h+Rqy+ysRbrpd-37Ev{iHi6w4OTp-WWMg~}VZgmywUaqacRn@=)X_oYRJzBnA z-O!SQEK2=JMh$C7sCWF{!;Ri@*;@ZdV-ngJExM0|7YwJQ4ew_I3kvj?%yL=1zV84V z^c$BE4mOnxq5t0QzVCl}7&{)Lm`6XyV_^m)kykzJxPR5lPWx98_Iv(SjQy{Fm0-(o zQ+hYa{?@-rvC}YIYU^V!`B(kyn4IQ#EX`i`uZG!)u2&8GR&3$u^Rwl$qvIz7Emq$XIBj)_C?j^jYMeikawRsL^82zI2W1~6uEX=Z70 z{&k^8(r}Z<@mAx})YAOy(uDu0fJfmFOzhYodwx4VC_*rv-pCO9UG`luKFr=?Q5J5t zy)5+TFzYVPoWO{Uo-YZ#Zx|-RE9SgiE*)!qc1!4~;lP(?zq!5FY~K-je3*r=I!5)3 z|NbDP`114HaWcCAUug2e%{wiVzo^8Fv&P8pi?(&V6Uf~5+J~L3!!+CbBH0EqwAjMr z3#|+KROqo`s60Qt_hNiC6^i$)rVoS;$L&=x9VkFj*8Ajapr}4)^76i%_>@h5*ti;lK8P?%Ia$9 zmPq9a#ffaLP>>w#o7;N)1L1qF9(z|P*U0SO&wh2A4V-h&n%))edQY0JxdO7D*xz7X zkyk}1euo9IsegdQ#%E@xAk`ytozY1?oS0~PDC1K!+>~{qA*;M1SxLrRVOsqISrZ?K zp36i>&j32#6}|H|$Y2FGcadvqsx34&6dlO5BW^;jAHiq_Zi)EhM0RE>Tb#-Qc}chb zi5Q*wGa+-5sIl%o&jj-(GUHLrcN^l4zN$RS$f{|Dn@mA)hGw4K>7enoW)um zuc6JObRcz*l-~Qu+8ST=A_v)#Ny;N~9b8_e8>*9=Uj$iP-Y}5;tCkS>NhWy+d7sZg z_0%DdhCuU~^kI4wpD)(}wjDi;z&i_KKR8N{5^A(XUiCKg$f%?knZ6_RDDqgUm;2brJEa60dBJe-~*}6b|^ZkbFY#dr7oVi18Q2GuFux@oT9Lvx+RcLG|&s%mLuMlNo3fM zf>ChYLp0ZYW!s;2a#l@CJBn8_gBp6X#A6Q8e3e`cXe@r#cFY?frAGOT@clgpOhUoa2JyyIJyI841;1C4SPgXojb(arjP*)J zPTdCTB7PMVc+;*_xaIdxQGdWqu9x~_`W_-r!JK`-N@=ir$Q+T9C^-WOnsP>49t%9v`Ck-GKO zMO3BbJ2i=#KvR>!jJ}k)etI~RVX1SKs_l4WJqR-xaWAtNkOC?d2ZA-lM=7r%KAuYP zr#i(Sq)rV}pl+mMB|xW&DcNb5K``IlR6N`wV=#MD#*r2o@l?t<-6`X~HW{hgU|l57 zSuT8}EFGssQf1=$SX~@=`*rQY*Pk?z*=le9xmL+NOPBc)#{h&Ag~ACTUAqSwXhrt^ zTOtMsrr#pL1cuzr(B(@3$Cf@`0Ae?NycM-oC5ceGFyo`Pyx4)r%0c(jiV25Qtwg4= zkb0;tg5qpYw^A!1P$Fzo5tgv6O*kLG{B=aO00|%uomSNr>KbvGAm}|{zyXM)r4Kf0 zG^!{f(dQr{KiJWK-TVRSXqjVFK{?PF#?M)*;G@Tn1$Cyd9QPo`%_PRzdjYhi$5BWt z+b&l#yt1wBJA{m!TH5L{a_U3=)UW*|)YCF_n7EoA`D?Y(#)=VJr-1*nS^VqAR_tu< zkW_buC8$(#m6uWHgptt7DbS48#l9vsYAf8-nsJBFHv%L?M=4N@={M@aTZGNcxvuGY zz;?|sS2sLVWBk`N)6%YS%UtJ{w!FIGm>zm*9IYRJ>qX6~IojIRn~qkZmU(W~yc^uv zL`1MP)ao>Q19g+RQU%=8MIGI<9f#BK`BxCoXw@pn5qqFDCVWa6a7algqn{v5?xdFi zjcwto4O&~6-HpkTj=mKbk9#h(Z8*?46y(2)q)+ZjzAjG8Ssso9@GPrcv^@$7Sq*hEujWckJzcxTO}A#P z`>&2*+AxW$lR*@Ja_8Q}e)Xk$7aQAU(2in5%yrDXL+o5#+<%dym=-LMwzRPh4P|yL zNOSo{NGhmNy#as0gbESJx++FWgI9VI+VDi;h1 zR=U1oIGfm>of=lM{(PGL9=urE&|M6K;C~U`!L`fM^gF~Z)fQ|#dj>9J!blJ@G;g*M|Gs5 zQ`rK1E;Do=J!-H1MDCM`sNR2F#E|%vjjGE_XT@Z`)E>uwhAxL; zwVwjckP(3D`94SR)O0rGWqlS4gylqD|LEDJeEn;IL* zV4|rv{quCRpGiF&xK}-V|1Z9v9$HqbM?2T1KUNp#0vs=I-K?#7+B!$Ao=dN|W}>XD z&>IHWN>MZe$v@6zOdP8L)wosS9znJbNx3m7E0egNeXS7(?^p5YyoqwSOQfa4ass%F zedMRmJK3=xne;}NAVw)uCbB~16a9EyXh;`5u9Zy=&w%U~5q1Dz?uKa*imLGP28cjL z3VMRQ5ePbTf~ClEt6o#o z`vRv0X;GX7ON4mA<{zw!p;j| zq;HGF)^Ed99pWZG#Hf_|N=XPn!l7?NQR8?klX!rAJ4k(#53t@v)0wxQBl~WpMiL>2 ztdd_*wbz%X0S2|Yun|MI-az>Dv{fUoO-@W`7v}TO7vcS!RpI3l>{6P^BPMbH95c#5 zOQuVV3P)QGx4^ne#h;J9z= z)fLza-#{TjGZd#S5vjShb#tTaVqXw<0WjFl8@z0xleAylkRYz4v3pv-9@lZeFq558 zVUQF?2X9hgN?GT@itpL%{5{rRfu3III!AngWQM)t|MN!8!Y2?geTnK8kvdm(zj+T(JQUKwQP>6&Br3Yb5j{qmbK&lkeTZ2&&7SpzaiSmutXf*QKLU^p-`#si0^m zFYd$HNAU#|e47p_R8C*^hg$`HK+@wa?RGS>XDpm z4M$O+q+(-xeNmUy#Hq(otyiT(zg8%#*Rn!QEJ-FHul~55641$GZ~R5^)9x<%`bkfI zAR32eZ3(nOvD|~C*P$_%3br6f%#YQ@XkZD_SO}Oyf> znKzsuAo;;EKzo&g=CX1}Q4qMq@vVV{0yz~X<*FvM`}|e;_0K7MHqwFqKL1m+EGJiS zv}F~JvAT)TA|Hw_!enA$Q!ol&6bcov1a1E)8&aq8%e;(oF)zsja~Y9GWdV|GFHfTn z9)+nA%E0*OWF3RISXks8(M5dGpT{>bio4jU+(|jWSaSfs2uqnZvTI+cz;h}7Aq>Fl zE+AaP%uksv~vo}j_{Gc2}fTRc#4hk;V`BWN$kz9(2rIle;m!?(pqu(RLb-VNkt z1ay?0wV<~zRgiNB6rxBYQbCX0_6bm6X%u*K7YWEnsnQTkM%jO0LycUp1KxkLE}jkg zOS7YZM=vTMhdR~(U*5wF#FrmoH6j$H%XB0qS0n=^d|QJyQC3)2aAaw!0_En=+>Eum zgLH9c$iyD(s51rBrCAeb%~o)99Aqn0lnd%>D(RoCi{jmhKoIVO5D$fSkRdXnJ!)mX z6;9ID^^LLUqTw0mtTh`x=38L=Wf3kE>5SXoc1j4)~ZKg;QTWlAS+ ziwEXvAikeVVz%C(@9tn;${VI>wEbf&-5l+z;ZQcU(~P7zC|vs3(VE9JW!CzS;1eGM zr3wGLG)fJRF#UCez#nwYA;y8bfGHRigeqxN6s*^|TjdCV2z#w0?8Gv65D;^7CGvEY z0?Cg#9|+9nl7Wzj>XoyCG0F&%0h0^rA3kdf$dVdR8l#IZOf?yGLEP{q4$i22wuU_- z!=myY>LIemaMyw+ovYC{QR%OUoG0MCGo;{wahy*91cQ>Jp1Lg}PuHBSHzhFAxh768 z0a@zgN2-|UFOlX`!2lP5$&CS$w?*$&s#_5im+w#QcwZX;%K2X|$xqCrg^jVyKpy2# z;)ysKHt^M~9?!QIO=oG&!Esa=3~QB_8?*M`7O}aN6)FfyglY&5jiEG{qfe7EiIX^d z_Ro=qPU3T4(?^HGc+un5vYavRHXGFv@pAa2(+;pi5Japd|L4$_V@lt zzCYQow69@DS*TekA!wbN2{1@PIliWn{`$EG4l1rULWrM{eu?HC8E5_e?gd{?l41QF loKhOrE>8JndT(evFz!o^S;6;~!CRAXMc>yb@4`>_{{gru#E1X@ literal 0 HcmV?d00001 diff --git a/desktop/installer/obj/Release/net48/MainWindow.g.cs b/desktop/installer/obj/Release/net48/MainWindow.g.cs new file mode 100644 index 00000000..7caff8c4 --- /dev/null +++ b/desktop/installer/obj/Release/net48/MainWindow.g.cs @@ -0,0 +1,842 @@ +#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "ED239D3706336E4C103F25FAD4E1E20AD19B6906" +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Diagnostics; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Controls.Ribbon; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Forms.Integration; +using System.Windows.Ink; +using System.Windows.Input; +using System.Windows.Markup; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Media.Effects; +using System.Windows.Media.Imaging; +using System.Windows.Media.Media3D; +using System.Windows.Media.TextFormatting; +using System.Windows.Navigation; +using System.Windows.Shapes; +using System.Windows.Shell; + + +namespace DeskHubSetup { + + + /// + /// MainWindow + /// + public partial class MainWindow : System.Windows.Window, System.Windows.Markup.IComponentConnector { + + + #line 140 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock VersionText; + + #line default + #line hidden + + + #line 142 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel Steps; + + #line default + #line hidden + + + #line 144 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Dot0; + + #line default + #line hidden + + + #line 144 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Num0; + + #line default + #line hidden + + + #line 145 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Label0; + + #line default + #line hidden + + + #line 147 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Line0; + + #line default + #line hidden + + + #line 149 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Dot1; + + #line default + #line hidden + + + #line 149 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Num1; + + #line default + #line hidden + + + #line 150 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Label1; + + #line default + #line hidden + + + #line 152 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Line1; + + #line default + #line hidden + + + #line 154 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Dot2; + + #line default + #line hidden + + + #line 154 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Num2; + + #line default + #line hidden + + + #line 155 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Label2; + + #line default + #line hidden + + + #line 157 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Line2; + + #line default + #line hidden + + + #line 159 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel ImportStepRow; + + #line default + #line hidden + + + #line 160 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Dot3; + + #line default + #line hidden + + + #line 160 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Num3; + + #line default + #line hidden + + + #line 161 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Label3; + + #line default + #line hidden + + + #line 163 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Line3; + + #line default + #line hidden + + + #line 165 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border Dot4; + + #line default + #line hidden + + + #line 165 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Num4; + + #line default + #line hidden + + + #line 166 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock Label4; + + #line default + #line hidden + + + #line 179 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock WindowTitle; + + #line default + #line hidden + + + #line 185 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Button CloseButton; + + #line default + #line hidden + + + #line 193 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel PageWelcome; + + #line default + #line hidden + + + #line 240 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border UpdateHint; + + #line default + #line hidden + + + #line 245 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock UpdateHintText; + + #line default + #line hidden + + + #line 248 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border RunningHint; + + #line default + #line hidden + + + #line 260 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.DockPanel PageLicense; + + #line default + #line hidden + + + #line 264 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.CheckBox AcceptLicense; + + #line default + #line hidden + + + #line 271 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel LicensePanel; + + #line default + #line hidden + + + #line 277 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel PageOptions; + + #line default + #line hidden + + + #line 286 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBox DirBox; + + #line default + #line hidden + + + #line 295 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.CheckBox DesktopLinkBox; + + #line default + #line hidden + + + #line 301 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel PageImport; + + #line default + #line hidden + + + #line 306 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel ImportList; + + #line default + #line hidden + + + #line 313 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel PageProgress; + + #line default + #line hidden + + + #line 314 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border SuccessIcon; + + #line default + #line hidden + + + #line 316 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Media.ScaleTransform SuccessScale; + + #line default + #line hidden + + + #line 321 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock ProgressTitle; + + #line default + #line hidden + + + #line 323 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel ProgressBarPanel; + + #line default + #line hidden + + + #line 324 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock PercentText; + + #line default + #line hidden + + + #line 326 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border ProgressTrack; + + #line default + #line hidden + + + #line 327 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border ProgressFill; + + #line default + #line hidden + + + #line 330 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock ProgressText; + + #line default + #line hidden + + + #line 332 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel DonePanel; + + #line default + #line hidden + + + #line 333 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.CheckBox LaunchBox; + + #line default + #line hidden + + + #line 338 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.StackPanel PageUninstall; + + #line default + #line hidden + + + #line 349 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock DataInfo; + + #line default + #line hidden + + + #line 350 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.CheckBox RemoveDataBox; + + #line default + #line hidden + + + #line 352 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock RemoveDataWarning; + + #line default + #line hidden + + + #line 362 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Button BackButton; + + #line default + #line hidden + + + #line 363 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Button NextButton; + + #line default + #line hidden + + + #line 364 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Button CancelButton; + + #line default + #line hidden + + + #line 368 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Grid DialogOverlay; + + #line default + #line hidden + + + #line 369 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Border DialogCard; + + #line default + #line hidden + + + #line 372 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Media.ScaleTransform DialogScale; + + #line default + #line hidden + + + #line 376 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock DialogIcon; + + #line default + #line hidden + + + #line 379 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock DialogTitle; + + #line default + #line hidden + + + #line 382 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.TextBlock DialogText; + + #line default + #line hidden + + + #line 385 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Button DialogSecondary; + + #line default + #line hidden + + + #line 386 "..\..\..\MainWindow.xaml" + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")] + internal System.Windows.Controls.Button DialogPrimary; + + #line default + #line hidden + + private bool _contentLoaded; + + /// + /// InitializeComponent + /// + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")] + public void InitializeComponent() { + if (_contentLoaded) { + return; + } + _contentLoaded = true; + System.Uri resourceLocater = new System.Uri("/DeskHub-Setup;component/mainwindow.xaml", System.UriKind.Relative); + + #line 1 "..\..\..\MainWindow.xaml" + System.Windows.Application.LoadComponent(this, resourceLocater); + + #line default + #line hidden + } + + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.12.0")] + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")] + void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) { + switch (connectionId) + { + case 1: + this.VersionText = ((System.Windows.Controls.TextBlock)(target)); + return; + case 2: + this.Steps = ((System.Windows.Controls.StackPanel)(target)); + return; + case 3: + this.Dot0 = ((System.Windows.Controls.Border)(target)); + return; + case 4: + this.Num0 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 5: + this.Label0 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 6: + this.Line0 = ((System.Windows.Controls.Border)(target)); + return; + case 7: + this.Dot1 = ((System.Windows.Controls.Border)(target)); + return; + case 8: + this.Num1 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 9: + this.Label1 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 10: + this.Line1 = ((System.Windows.Controls.Border)(target)); + return; + case 11: + this.Dot2 = ((System.Windows.Controls.Border)(target)); + return; + case 12: + this.Num2 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 13: + this.Label2 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 14: + this.Line2 = ((System.Windows.Controls.Border)(target)); + return; + case 15: + this.ImportStepRow = ((System.Windows.Controls.StackPanel)(target)); + return; + case 16: + this.Dot3 = ((System.Windows.Controls.Border)(target)); + return; + case 17: + this.Num3 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 18: + this.Label3 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 19: + this.Line3 = ((System.Windows.Controls.Border)(target)); + return; + case 20: + this.Dot4 = ((System.Windows.Controls.Border)(target)); + return; + case 21: + this.Num4 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 22: + this.Label4 = ((System.Windows.Controls.TextBlock)(target)); + return; + case 23: + this.WindowTitle = ((System.Windows.Controls.TextBlock)(target)); + return; + case 24: + + #line 183 "..\..\..\MainWindow.xaml" + ((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.Minimize_Click); + + #line default + #line hidden + return; + case 25: + this.CloseButton = ((System.Windows.Controls.Button)(target)); + + #line 185 "..\..\..\MainWindow.xaml" + this.CloseButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click); + + #line default + #line hidden + return; + case 26: + this.PageWelcome = ((System.Windows.Controls.StackPanel)(target)); + return; + case 27: + this.UpdateHint = ((System.Windows.Controls.Border)(target)); + return; + case 28: + this.UpdateHintText = ((System.Windows.Controls.TextBlock)(target)); + return; + case 29: + this.RunningHint = ((System.Windows.Controls.Border)(target)); + return; + case 30: + this.PageLicense = ((System.Windows.Controls.DockPanel)(target)); + return; + case 31: + this.AcceptLicense = ((System.Windows.Controls.CheckBox)(target)); + + #line 265 "..\..\..\MainWindow.xaml" + this.AcceptLicense.Checked += new System.Windows.RoutedEventHandler(this.AcceptLicense_Click); + + #line default + #line hidden + + #line 265 "..\..\..\MainWindow.xaml" + this.AcceptLicense.Unchecked += new System.Windows.RoutedEventHandler(this.AcceptLicense_Click); + + #line default + #line hidden + return; + case 32: + this.LicensePanel = ((System.Windows.Controls.StackPanel)(target)); + return; + case 33: + this.PageOptions = ((System.Windows.Controls.StackPanel)(target)); + return; + case 34: + + #line 285 "..\..\..\MainWindow.xaml" + ((System.Windows.Controls.Button)(target)).Click += new System.Windows.RoutedEventHandler(this.ChangeDir_Click); + + #line default + #line hidden + return; + case 35: + this.DirBox = ((System.Windows.Controls.TextBox)(target)); + return; + case 36: + this.DesktopLinkBox = ((System.Windows.Controls.CheckBox)(target)); + return; + case 37: + this.PageImport = ((System.Windows.Controls.StackPanel)(target)); + return; + case 38: + this.ImportList = ((System.Windows.Controls.StackPanel)(target)); + return; + case 39: + this.PageProgress = ((System.Windows.Controls.StackPanel)(target)); + return; + case 40: + this.SuccessIcon = ((System.Windows.Controls.Border)(target)); + return; + case 41: + this.SuccessScale = ((System.Windows.Media.ScaleTransform)(target)); + return; + case 42: + this.ProgressTitle = ((System.Windows.Controls.TextBlock)(target)); + return; + case 43: + this.ProgressBarPanel = ((System.Windows.Controls.StackPanel)(target)); + return; + case 44: + this.PercentText = ((System.Windows.Controls.TextBlock)(target)); + return; + case 45: + this.ProgressTrack = ((System.Windows.Controls.Border)(target)); + + #line 326 "..\..\..\MainWindow.xaml" + this.ProgressTrack.SizeChanged += new System.Windows.SizeChangedEventHandler(this.ProgressTrack_SizeChanged); + + #line default + #line hidden + return; + case 46: + this.ProgressFill = ((System.Windows.Controls.Border)(target)); + return; + case 47: + this.ProgressText = ((System.Windows.Controls.TextBlock)(target)); + return; + case 48: + this.DonePanel = ((System.Windows.Controls.StackPanel)(target)); + return; + case 49: + this.LaunchBox = ((System.Windows.Controls.CheckBox)(target)); + return; + case 50: + this.PageUninstall = ((System.Windows.Controls.StackPanel)(target)); + return; + case 51: + this.DataInfo = ((System.Windows.Controls.TextBlock)(target)); + return; + case 52: + this.RemoveDataBox = ((System.Windows.Controls.CheckBox)(target)); + + #line 351 "..\..\..\MainWindow.xaml" + this.RemoveDataBox.Checked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click); + + #line default + #line hidden + + #line 351 "..\..\..\MainWindow.xaml" + this.RemoveDataBox.Unchecked += new System.Windows.RoutedEventHandler(this.RemoveDataBox_Click); + + #line default + #line hidden + return; + case 53: + this.RemoveDataWarning = ((System.Windows.Controls.TextBlock)(target)); + return; + case 54: + this.BackButton = ((System.Windows.Controls.Button)(target)); + + #line 362 "..\..\..\MainWindow.xaml" + this.BackButton.Click += new System.Windows.RoutedEventHandler(this.Back_Click); + + #line default + #line hidden + return; + case 55: + this.NextButton = ((System.Windows.Controls.Button)(target)); + + #line 363 "..\..\..\MainWindow.xaml" + this.NextButton.Click += new System.Windows.RoutedEventHandler(this.Next_Click); + + #line default + #line hidden + return; + case 56: + this.CancelButton = ((System.Windows.Controls.Button)(target)); + + #line 364 "..\..\..\MainWindow.xaml" + this.CancelButton.Click += new System.Windows.RoutedEventHandler(this.Cancel_Click); + + #line default + #line hidden + return; + case 57: + this.DialogOverlay = ((System.Windows.Controls.Grid)(target)); + return; + case 58: + this.DialogCard = ((System.Windows.Controls.Border)(target)); + return; + case 59: + this.DialogScale = ((System.Windows.Media.ScaleTransform)(target)); + return; + case 60: + this.DialogIcon = ((System.Windows.Controls.TextBlock)(target)); + return; + case 61: + this.DialogTitle = ((System.Windows.Controls.TextBlock)(target)); + return; + case 62: + this.DialogText = ((System.Windows.Controls.TextBlock)(target)); + return; + case 63: + this.DialogSecondary = ((System.Windows.Controls.Button)(target)); + + #line 385 "..\..\..\MainWindow.xaml" + this.DialogSecondary.Click += new System.Windows.RoutedEventHandler(this.DialogSecondary_Click); + + #line default + #line hidden + return; + case 64: + this.DialogPrimary = ((System.Windows.Controls.Button)(target)); + + #line 386 "..\..\..\MainWindow.xaml" + this.DialogPrimary.Click += new System.Windows.RoutedEventHandler(this.DialogPrimary_Click); + + #line default + #line hidden + return; + } + this._contentLoaded = true; + } + } +} + diff --git a/desktop/installer/obj/Release/net48/Themes/Colors.Dark.baml b/desktop/installer/obj/Release/net48/Themes/Colors.Dark.baml new file mode 100644 index 0000000000000000000000000000000000000000..17c32ce241a7bf6445254ee2e4e5fcd47ddfcd65 GIT binary patch literal 1917 zcmb_c%Wl&^6g_#hq)D51%AyjH3RzWoCryyLNK;u+4EeZFlOuW{cFiy_LNPc0~G3#riK@c@@PAky4`O*xEL1a$>rTzr~LK zY5HCaJEAX#%9u^*#&i6;38h6K&fk91bga?6B=GeDu>sYoSy~EV;fXROru(dv4Oejqh2EBI}9#gjnRrORyY6&XZ-L6V>+h{2bC^yppEto^xRy z86nPOUcQ5zPy?xpab$%)yStSI+?|n)li7#41XA0^nPW>-bu6#yViNIvGO<0YxQHyb zWTH~zNHM2kYfH#*d7qfJcBQ6LNJ#pWIwH7`M#i&eixx7-Y-x&4Pb5lhzhU_->!Qkz zx4u7e#zU`6I)~&l)>B;(b<=qyWk*$6OFfTN0f{ZeIPudg6|o=@Llv`0Eh8b16n1^w zc}9L;ApZ!xk!_zntX3^`M8=9$8EdStMUU-@O2^f6%{ZP^8?{=eI$g0#d!t;Y%rYNmpxvU@F@TZ6vT4rY_2Ua#Nsz1&c#Q6F${@$+RUlbBz<$`uX&0o|hvDgXcg literal 0 HcmV?d00001 diff --git a/desktop/installer/obj/Release/net48/Themes/Colors.Light.baml b/desktop/installer/obj/Release/net48/Themes/Colors.Light.baml new file mode 100644 index 0000000000000000000000000000000000000000..8d10637b7a3720fed7230234aa53758c8ba52e2f GIT binary patch literal 1917 zcmb_cOHUI~7(IOfrG>tW3lf{4tVmnv14_bzw%kZmBn%N^jBz?#=!EH9G7o9@?)(An z-0Q~p2h@fC!T;deEx~(dZb?VkXuK!QobSwczi)c(BMIO!6tuC;pO1L-^5z%9H6K_! z0+fA=+SGJZM`vb-S#+iJlv*|qw(8}oz)Gp(8jfqxEt9&Ar5Y=xeYa=m+Cw@zWare} zs;%};Ya5NGdQxlFX|1vNJcQ%Py2Mfk^paWU|D!k!Lsdq$LkHy}-8^BJcDch$$6|(E zKG1tTX8uE$-uk-Q&>GFvR;^B}8?D8I2=-+8wrc+uS6)Q%OqL|f8rZw0L(eSF_<#7~ zKTJP}VNd#UtPc1p{q;KjZbE4eh>L&Uwp_b^Hwk>cLaf6KW|ihcn0=&#SXFQiCK_31#h%LAP^w%Msu*4UtOshg+6%38JBtPJ@Mt6WT69Wj2n zgb+pqQRFv6!N2 w+XCY(&$W8@7B8w~yi_@% literal 0 HcmV?d00001 diff --git a/desktop/installer/obj/Release/net48/Themes/Controls.baml b/desktop/installer/obj/Release/net48/Themes/Controls.baml new file mode 100644 index 0000000000000000000000000000000000000000..e82be680a450d342be1cada6f20302bac2064d01 GIT binary patch literal 13030 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 literal 0 HcmV?d00001 diff --git a/desktop/scripts/build-setup.js b/desktop/scripts/build-setup.js new file mode 100644 index 00000000..41fad8b9 --- /dev/null +++ b/desktop/scripts/build-setup.js @@ -0,0 +1,94 @@ +'use strict'; + +/* + * build-setup.js – eigenes Setup (installer/) mit der App zusammenbauen + * --------------------------------------------------------------------- + * Läuft nach "electron-builder --win dir" (siehe "build" in package.json): + * + * 1. dist/win-unpacked (die fertige App) als ZIP packen + * 2. installer/ (WPF, .NET Framework 4.8) mit der Version aus package.json bauen + * 3. ZIP hinten an die Setup-.exe hängen: + * [Setup.exe][ZIP][8 Byte ZIP-Länge][8 Byte "DESKHUBP"] + * (das Setup liest den Anhang selbst aus, siehe installer/Payload.cs) + * 4. Zwischenstände wegräumen → übrig bleibt dist/DeskHub-Setup.exe + * + * Benötigt das .NET SDK (dotnet) – das Setup selbst läuft danach auf jedem + * Windows 10/11 ohne Zusatzinstallation. + */ + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const DIST = path.join(ROOT, 'dist'); +const APP_DIR = path.join(DIST, 'win-unpacked'); +const WORK = path.join(DIST, '.setup'); +const ZIP = path.join(WORK, 'app.zip'); +const OUT = path.join(DIST, 'DeskHub-Setup.exe'); +const MAGIC = Buffer.from('DESKHUBP', 'ascii'); + +function schritt(text) { + console.log(`\n» ${text}`); +} + +function main() { + const version = require(path.join(ROOT, 'package.json')).version; + if (!fs.existsSync(path.join(APP_DIR, 'DeskHub.exe'))) { + throw new Error(`${APP_DIR}\\DeskHub.exe fehlt – vorher "electron-builder --win dir" ausführen.`); + } + fs.rmSync(WORK, { recursive: true, force: true }); + fs.mkdirSync(WORK, { recursive: true }); + + schritt('App wird gepackt …'); + // .NET-ZIP über PowerShell: in jedem Windows enthalten, keine zusätzliche npm-Abhängigkeit + const ps = [ + 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + `[System.IO.Compression.ZipFile]::CreateFromDirectory('${APP_DIR.replace(/'/g, "''")}', '${ZIP.replace(/'/g, "''")}', [System.IO.Compression.CompressionLevel]::Optimal, $false)`, + ].join('; '); + execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { stdio: 'inherit' }); + + schritt(`Setup ${version} wird gebaut …`); + const stubDir = path.join(WORK, 'stub'); + execFileSync('dotnet', [ + 'build', path.join(ROOT, 'installer', 'DeskHubSetup.csproj'), + '-c', 'Release', `-p:Version=${version}`, '-p:DebugType=none', + '--nologo', '-v', 'q', '-o', stubDir, + ], { stdio: 'inherit' }); + const stub = path.join(stubDir, 'DeskHub-Setup.exe'); + if (!fs.existsSync(stub)) throw new Error('Build des Setups fehlgeschlagen.'); + + schritt('App wird ans Setup angehängt …'); + const zipSize = fs.statSync(ZIP).size; + const footer = Buffer.alloc(16); + footer.writeBigInt64LE(BigInt(zipSize), 0); + MAGIC.copy(footer, 8); + fs.rmSync(OUT, { force: true }); + fs.copyFileSync(stub, OUT); + // ZIP in Blöcken anhängen statt komplett in den Speicher zu laden + const fd = fs.openSync(OUT, 'a'); + try { + const src = fs.openSync(ZIP, 'r'); + try { + const buf = Buffer.alloc(8 * 1024 * 1024); + let n; + while ((n = fs.readSync(src, buf, 0, buf.length, null)) > 0) fs.writeSync(fd, buf, 0, n); + } finally { fs.closeSync(src); } + fs.writeSync(fd, footer); + } finally { fs.closeSync(fd); } + + schritt('Aufräumen …'); + for (const t of [WORK, APP_DIR, path.join(DIST, 'builder-debug.yml'), path.join(DIST, 'builder-effective-config.yaml')]) { + fs.rmSync(t, { recursive: true, force: true }); + } + + const mb = (fs.statSync(OUT).size / 1024 / 1024).toFixed(1); + console.log(`\nFertig: ${OUT} (${mb} MB)`); +} + +try { + main(); +} catch (err) { + console.error(`\nFehler: ${err.message}`); + process.exit(1); +} diff --git a/desktop/src/admin-extra.js b/desktop/src/admin-extra.js new file mode 100644 index 00000000..24895b50 --- /dev/null +++ b/desktop/src/admin-extra.js @@ -0,0 +1,184 @@ +'use strict'; +// Zusätzliche Verwaltungsbereiche (wie in der Android-App): Teams, Rollen, Eskalationen, +// Geschäftszeiten, E-Mail-Vorlagen und Ticket-Regeln. Jeder Bereich rendert sich selbst in `body`. + +const PERM_LABELS = { + reply_ticket: 'Tickets beantworten', close_ticket: 'Tickets lösen/schließen', delete_ticket: 'Tickets löschen', transfer_ticket: 'Tickets verschieben', + post_note: 'Interne Notizen', assign_ticket: 'Tickets zuweisen', manage_canned: 'Textbausteine verwalten', view_reports: 'Berichte ansehen', + create_ticket: 'Tickets erstellen', manage_kb: 'Wissensdatenbank', manage_departments: 'Abteilungen', manage_priorities: 'Prioritäten / SLA', + manage_help_topics: 'Hilfe-Themen', manage_email_addresses: 'E-Mail-Adressen', manage_email_templates: 'E-Mail-Vorlagen', manage_organizations: 'Organisationen', + manage_teams: 'Teams', manage_roles: 'Rollen', manage_staff: 'Mitarbeiter', view_audit_log: 'Admin-Protokoll', manage_escalations: 'Eskalationsregeln', + view_time_entries: 'Zeiterfassung', manage_business_hours: 'Geschäftszeiten', +}; +const WEEKDAYS = { 1: 'Montag', 2: 'Dienstag', 3: 'Mittwoch', 4: 'Donnerstag', 5: 'Freitag', 6: 'Samstag', 0: 'Sonntag' }; + +const opt = (arr, val, empty) => [empty ? h('option', { value: '' }, empty) : null, ...arr.map(([v, l]) => h('option', { value: v, selected: String(v) === String(val ?? '') }, l))]; +const named = (list) => list.map((x) => [x.id, x.name]); +const numOrNull = (v) => (v === '' || v == null ? null : Number(v)); +const row = (...kids) => h('div', { class: 'row' }, ...kids); +const delBtn = (fn) => h('button', { class: 'btn danger sm', onclick: (e) => { e.stopPropagation(); if (confirm('Wirklich löschen?')) fn(); } }, '✕'); +const table = (heads, rows) => h('div', { class: 'panel' }, rows.length ? h('table', {}, h('tr', {}, ...heads.map((t) => h('th', {}, t))), ...rows) : h('div', { class: 'muted' }, 'Keine Einträge.')); + +// ─── Teams ─────────────────────────────────────────────────────────────────── +async function adminTeams(body, addBtn) { + const load = async () => { + const r = await run(() => api('teams.php')); if (!r) return; + clear(body).append(table(['Name', 'Leitung', 'Mitglieder', 'Aktiv', 'Aktualisiert', ''], r.items.map((t) => h('tr', { class: 'click', onclick: () => edit(t, r) }, + h('td', {}, t.name), h('td', {}, t.leadName || '—'), h('td', {}, t.memberCount), + h('td', {}, h('input', { type: 'checkbox', checked: t.isActive, onclick: (e) => e.stopPropagation(), onchange: () => run(async () => { await post('teams.php', { action: 'toggle', id: t.id }); load(); }) })), + h('td', { class: 'muted' }, t.updatedAt ? new Date(t.updatedAt.replace(' ', 'T')).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''), + h('td', {}, delBtn(() => run(async () => { await post('teams.php', { action: 'delete', id: t.id }); load(); }, 'Gelöscht'))))))); + addBtn.onclick = () => edit(null, r); + }; + function edit(t, r) { + const name = h('input', { value: t?.name || '' }), notes = h('textarea', { value: t?.notes || '' }); + const lead = h('select', {}, ...opt(named(r.staffOptions), t?.leadId, '— keine —')); + const active = h('input', { type: 'checkbox', checked: t ? t.isActive : true }), noAlerts = h('input', { type: 'checkbox', checked: !!t?.noAlerts }); + const members = r.staffOptions.map((s) => ({ s, cb: h('input', { type: 'checkbox', checked: t?.memberIds?.includes(s.id) }) })); + modal(t ? 'Team bearbeiten' : 'Neues Team', h('div', {}, h('label', {}, 'Name'), name, h('label', {}, 'Leitung'), lead, h('label', { class: 'check' }, active, 'Aktiv'), + h('label', { class: 'check' }, noAlerts, 'Benachrichtigung bei Ticketzuweisung deaktivieren'), h('label', {}, 'Administratornotizen (intern)'), notes, + h('label', {}, 'Mitglieder'), h('div', { class: 'checks' }, ...members.map(({ s, cb }) => h('label', { class: 'check' }, cb, s.name)))), + [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('teams.php', { action: 'save', id: t?.id ?? null, name: name.value, leadId: numOrNull(lead.value), isActive: active.checked, noAlerts: noAlerts.checked, notes: notes.value || null, memberIds: members.filter((m) => m.cb.checked).map((m) => m.s.id) }), 'Gespeichert')) { close(); load(); } } }]); + } + load(); +} + +// ─── Rollen ────────────────────────────────────────────────────────────────── +async function adminRoles(body, addBtn) { + const load = async () => { + const r = await run(() => api('roles.php')); if (!r) return; + clear(body).append(table(['Rolle', 'Status', 'Zugewiesen', 'Rechte', ''], r.items.map((x) => h('tr', { class: 'click', onclick: () => edit(x, r) }, + h('td', {}, x.name), h('td', {}, x.isActive ? 'Aktiv' : 'Deaktiviert'), h('td', {}, x.staffCount), h('td', { class: 'muted' }, Object.values(x.permissions).filter(Boolean).length + ' von ' + r.permissionKeys.length), + h('td', {}, delBtn(() => run(async () => { await post('roles.php', { action: 'delete', id: x.id }); load(); }, 'Gelöscht'))))))); + addBtn.onclick = () => edit(null, r); + }; + function edit(x, r) { + const name = h('input', { value: x?.name || '' }), notes = h('textarea', { value: x?.notes || '' }); + const active = h('input', { type: 'checkbox', checked: x ? x.isActive : true }); + const perms = (x?.permissions) || r.defaultAgentPermissions || {}; + const boxes = r.permissionKeys.map((k) => ({ k, cb: h('input', { type: 'checkbox', checked: !!perms[k] }) })); + modal(x ? 'Rolle bearbeiten' : 'Neue Rolle', h('div', {}, h('label', {}, 'Name'), name, h('label', { class: 'check' }, active, 'Aktiv (deaktivierte Rolle wird ignoriert)'), h('label', {}, 'Administratornotizen (intern)'), notes, h('label', {}, 'Rechte'), + h('div', { class: 'checks' }, ...boxes.map(({ k, cb }) => h('label', { class: 'check' }, cb, PERM_LABELS[k] || k)))), + [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('roles.php', { action: 'save', id: x?.id ?? null, name: name.value, isActive: active.checked, notes: notes.value || null, permissions: Object.fromEntries(boxes.map(({ k, cb }) => [k, cb.checked])) }), 'Gespeichert')) { close(); load(); } } }]); + } + load(); +} + +// ─── Eskalationsregeln ─────────────────────────────────────────────────────── +const TRIGGERS = [['no_reply', 'Keine Antwort seit …'], ['no_update', 'Keine Aktualisierung seit …'], ['overdue', 'Überfällig seit …'], ['open_too_long', 'Zu lange offen seit …']]; +async function adminEscalations(body, addBtn) { + const load = async () => { + const r = await run(() => api('escalation_rules.php')); if (!r) return; + clear(body).append(table(['Regel', 'Auslöser', 'Angewendet', 'Aktiv', ''], r.rules.map((x) => h('tr', { class: 'click', onclick: () => edit(x, r) }, + h('td', {}, x.name), h('td', {}, `${(TRIGGERS.find((t) => t[0] === x.triggerType) || [, x.triggerType])[1].replace('…', x.triggerHours + ' Std.')}`), h('td', {}, x.appliedCount), + h('td', {}, h('input', { type: 'checkbox', checked: x.isActive, onclick: (e) => e.stopPropagation(), onchange: () => run(async () => { await post('escalation_rules.php', { action: 'toggle', id: x.id }); load(); }) })), + h('td', {}, delBtn(() => run(async () => { await post('escalation_rules.php', { action: 'delete', id: x.id }); load(); }, 'Gelöscht'))))))); + addBtn.onclick = () => edit(null, r); + }; + function edit(x, r) { + const f = { + name: h('input', { value: x?.name || '' }), active: h('input', { type: 'checkbox', checked: x?.isActive ?? true }), + trig: h('select', {}, ...opt(TRIGGERS, x?.triggerType || 'no_reply')), hours: h('input', { type: 'number', min: 1, value: x?.triggerHours ?? 24 }), + cStatus: h('select', {}, ...opt(state.meta.statuses.map((s) => [s.value, s.label]), x?.conditionStatus, 'Beliebig')), + cPrio: h('select', {}, ...opt(named(r.priorities), x?.conditionPriorityId, 'Beliebig')), cDept: h('select', {}, ...opt(named(r.departments), x?.conditionDeptId, 'Beliebig')), + aPrio: h('select', {}, ...opt(named(r.priorities), x?.actionSetPriorityId, '— nicht ändern —')), aStaff: h('select', {}, ...opt(named(r.staffOptions), x?.actionAssignStaffId, '— nicht ändern —')), + aStatus: h('select', {}, ...opt([['open', 'Offen'], ['pending', 'Wartend'], ['on_hold', 'Zurückgestellt']], x?.actionSetStatus, '— nicht ändern —')), + nAdmin: h('input', { type: 'checkbox', checked: x?.actionNotifyAdmin ?? true }), nStaff: h('input', { type: 'checkbox', checked: !!x?.actionNotifyStaff }), + }; + modal(x ? 'Regel bearbeiten' : 'Neue Eskalationsregel', h('div', {}, h('label', {}, 'Name'), f.name, h('label', { class: 'check' }, f.active, 'Aktiv'), + h('label', {}, 'Auslöser'), f.trig, h('label', {}, 'Nach Stunden'), f.hours, + h('h3', {}, 'Bedingungen'), h('label', {}, 'Status'), f.cStatus, h('label', {}, 'Priorität'), f.cPrio, h('label', {}, 'Abteilung'), f.cDept, + h('h3', {}, 'Aktionen'), h('label', {}, 'Priorität setzen'), f.aPrio, h('label', {}, 'Zuweisen an'), f.aStaff, h('label', {}, 'Status setzen'), f.aStatus, + h('label', { class: 'check' }, f.nAdmin, 'Admins benachrichtigen'), h('label', { class: 'check' }, f.nStaff, 'Zuständigen benachrichtigen')), + [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('escalation_rules.php', { action: 'save', id: x?.id ?? null, name: f.name.value, isActive: f.active.checked, triggerType: f.trig.value, triggerHours: +f.hours.value || 24, + conditionStatus: f.cStatus.value || null, conditionPriorityId: numOrNull(f.cPrio.value), conditionDeptId: numOrNull(f.cDept.value), actionSetPriorityId: numOrNull(f.aPrio.value), + actionAssignStaffId: numOrNull(f.aStaff.value), actionSetStatus: f.aStatus.value || null, actionNotifyAdmin: f.nAdmin.checked, actionNotifyStaff: f.nStaff.checked, sortOrder: x?.sortOrder ?? 0 }), 'Gespeichert')) { close(); load(); } } }]); + } + load(); +} + +// ─── Geschäftszeiten ───────────────────────────────────────────────────────── +async function adminBusinessHours(body, addBtn) { + addBtn.classList.add('hidden'); + const load = async () => { + const r = await run(() => api('business_hours.php')); if (!r) return; + const enabled = h('input', { type: 'checkbox', checked: r.enabled }); + const rows = r.hours.map((x) => ({ x, open: h('input', { type: 'checkbox', checked: x.isOpen }), from: h('input', { type: 'time', value: x.openTime }), to: h('input', { type: 'time', value: x.closeTime }) })); + const hDate = h('input', { type: 'date' }), hName = h('input', { placeholder: 'Bezeichnung (z. B. Neujahr)' }); + clear(body).append(h('div', { class: 'panel' }, h('label', { class: 'check' }, enabled, 'SLA-Uhr nur innerhalb der Geschäftszeiten laufen lassen'), + h('table', {}, ...rows.map(({ x, open, from, to }) => h('tr', {}, h('td', {}, WEEKDAYS[x.weekday]), h('td', {}, open), h('td', {}, from), h('td', {}, to)))), + row(h('button', { class: 'btn', onclick: () => run(() => post('business_hours.php', { action: 'save_hours', enabled: enabled.checked, + hours: rows.map(({ x, open, from, to }) => ({ weekday: x.weekday, isOpen: open.checked, openTime: from.value || '09:00', closeTime: to.value || '17:00' })) }), 'Gespeichert') }, 'Speichern'))), + h('h3', {}, 'Feiertage / Schließtage'), + h('div', { class: 'panel' }, row(hDate, hName, h('button', { class: 'btn', onclick: () => hDate.value && hName.value && run(async () => { await post('business_hours.php', { action: 'add_holiday', date: hDate.value, name: hName.value }); load(); }) }, 'Hinzufügen')), + ...r.holidays.map((hd) => row(h('span', { class: 'grow' }, `${hd.date} – ${hd.name}`), delBtn(() => run(async () => { await post('business_hours.php', { action: 'delete_holiday', id: hd.id }); load(); })))))); + }; + load(); +} + +// ─── E-Mail-Vorlagen ───────────────────────────────────────────────────────── +async function adminEmailTemplates(body, addBtn) { + addBtn.classList.add('hidden'); + const load = async () => { + const r = await run(() => api('email_templates.php')); if (!r) return; + clear(body).append(table(['Vorlage', 'Angepasst'], r.items.map((t) => h('tr', { class: 'click', onclick: () => edit(t) }, h('td', {}, t.label), h('td', {}, t.isCustomized ? '✓' : ''))))); + }; + function edit(t) { + const subject = h('input', { value: t.subject }), text = h('textarea', { value: t.body, class: 'tall' }); + modal(t.label, h('div', {}, h('label', {}, 'Betreff'), subject, h('label', {}, 'Text'), text, + h('div', { class: 'muted' }, 'Platzhalter: ' + t.placeholders.join(' '))), + [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('email_templates.php', { action: 'save', key: t.key, subject: subject.value, body: text.value }), 'Gespeichert')) { close(); load(); } } }, + ...(t.isCustomized ? [{ label: 'Zurücksetzen', cls: 'ghost', fn: async (close) => { if (confirm('Auf Standard zurücksetzen?') && await run(() => post('email_templates.php', { action: 'reset', key: t.key }), 'Zurückgesetzt')) { close(); load(); } } }] : [])]); + } + load(); +} + +// ─── Ticket-Regeln ─────────────────────────────────────────────────────────── +async function adminTicketRules(body, addBtn) { + const load = async () => { + const r = await run(() => api('ticket_rules.php')); if (!r) return; + const label = (list, v) => (list.find((o) => o.value === v) || { label: v }).label; + const acEnabled = h('input', { type: 'checkbox', checked: r.autoCloseEnabled }), acDays = h('input', { type: 'number', min: 1, value: r.autoCloseDays, class: 'narrow' }); + clear(body).append( + h('div', { class: 'panel' }, h('b', {}, 'Automatisch schließen'), row(h('label', { class: 'check' }, acEnabled, 'Beantwortete Tickets nach'), acDays, h('span', {}, 'Tagen schließen'), + h('button', { class: 'btn sm', onclick: () => run(() => post('ticket_rules.php', { action: 'save_auto_close', enabled: acEnabled.checked, days: +acDays.value || 7 }), 'Gespeichert') }, 'Speichern'))), + table(['Regel', 'Bedingungen', 'Aktionen', 'Aktiv', ''], r.rules.map((x) => h('tr', { class: 'click', onclick: () => edit(x, r) }, + h('td', {}, x.name), h('td', { class: 'muted' }, x.conditions.map((c) => `${label(r.fieldOptions, c.field)} ${label(r.operatorOptions, c.operator)} „${c.value}“`).join(x.matchType === 'any' ? ' ODER ' : ' UND ')), + h('td', { class: 'muted' }, x.actions.map((a) => label(r.actionOptions, a.type) + (a.valueLabel ? `: ${a.valueLabel}` : '')).join(', ')), + h('td', {}, h('input', { type: 'checkbox', checked: x.isActive, onclick: (e) => e.stopPropagation(), onchange: () => run(async () => { await post('ticket_rules.php', { action: 'toggle', id: x.id }); load(); }) })), + h('td', {}, delBtn(() => run(async () => { await post('ticket_rules.php', { action: 'delete', id: x.id }); load(); }, 'Gelöscht'))))))); + addBtn.onclick = () => edit(null, r); + }; + function edit(x, r) { + const name = h('input', { value: x?.name || '' }), match = h('select', {}, ...opt([['all', 'Alle Bedingungen müssen zutreffen'], ['any', 'Mindestens eine Bedingung']], x?.matchType || 'all')); + const active = h('input', { type: 'checkbox', checked: x?.isActive ?? true }), sort = h('input', { type: 'number', value: x?.sortOrder ?? 0 }); + const conds = h('div'), acts = h('div'); + const valueSource = { set_department: r.departments, set_priority: r.priorities, set_help_topic: r.helpTopics, assign_staff: r.staffOptions, auto_reply: r.cannedResponses }; + const addCond = (c) => { const rowEl = h('div', { class: 'row rule' }, h('select', { 'data-f': 'field' }, ...opt(r.fieldOptions.map((o) => [o.value, o.label]), c?.field)), + h('select', { 'data-f': 'operator' }, ...opt(r.operatorOptions.map((o) => [o.value, o.label]), c?.operator)), h('input', { 'data-f': 'value', value: c?.value || '', placeholder: 'Wert' }), + h('button', { class: 'btn ghost sm', onclick: () => rowEl.remove() }, '✕')); conds.append(rowEl); }; + const addAct = (a) => { + const rowEl = h('div', { class: 'row rule' }), valHolder = h('span', { class: 'grow' }); + const type = h('select', { 'data-f': 'type', onchange: () => paint(type.value, '') }, ...opt(r.actionOptions.map((o) => [o.value, o.label]), a?.type)); + const paint = (t, v) => { clear(valHolder); if (t === 'reject') return; const src = valueSource[t]; + valHolder.append(src ? h('select', { 'data-f': 'value' }, ...opt(named(src), v)) : h('input', { 'data-f': 'value', value: v || '', placeholder: 'Wert' })); }; + rowEl.append(type, valHolder, h('button', { class: 'btn ghost sm', onclick: () => rowEl.remove() }, '✕')); acts.append(rowEl); paint(type.value, a?.value); + }; + (x?.conditions?.length ? x.conditions : [null]).forEach(addCond); (x?.actions?.length ? x.actions : [null]).forEach(addAct); + const collect = (box) => [...box.children].map((el) => Object.fromEntries([...el.querySelectorAll('[data-f]')].map((i) => [i.dataset.f, i.value]))); + modal(x ? 'Regel bearbeiten' : 'Neue Ticket-Regel', h('div', {}, h('label', {}, 'Name'), name, h('label', {}, 'Verknüpfung'), match, h('label', { class: 'check' }, active, 'Aktiv'), h('label', {}, 'Reihenfolge'), sort, + h('h3', {}, 'Wenn …'), conds, h('button', { class: 'btn ghost sm', onclick: () => addCond(null) }, '+ Bedingung'), h('h3', {}, 'Dann …'), acts, h('button', { class: 'btn ghost sm', onclick: () => addAct(null) }, '+ Aktion')), + [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('ticket_rules.php', { action: 'save', id: x?.id ?? null, name: name.value, matchType: match.value, isActive: active.checked, sortOrder: +sort.value || 0, + conditions: collect(conds).map((c) => ({ field: c.field, operator: c.operator, value: c.value || '' })), actions: collect(acts).map((a) => ({ type: a.type, value: a.value || '' })) }), 'Gespeichert')) { close(); load(); } } }]); + } + load(); +} + +ADMIN.push( + { key: 'teams', label: 'Teams', perm: 'manage_teams', custom: adminTeams, add: true }, + { key: 'roles', label: 'Rollen', perm: 'manage_roles', custom: adminRoles, add: true }, + { key: 'escalations', label: 'Eskalationsregeln', perm: 'manage_escalations', custom: adminEscalations, add: true }, + { key: 'business_hours', label: 'Geschäftszeiten', perm: 'manage_business_hours', custom: adminBusinessHours }, + { key: 'email_templates', label: 'E-Mail-Vorlagen', perm: 'manage_email_templates', custom: adminEmailTemplates }, + { key: 'ticket_rules', label: 'Ticket-Regeln', admin: true, custom: adminTicketRules, add: true }, +); diff --git a/desktop/src/app.js b/desktop/src/app.js new file mode 100644 index 00000000..7398fe78 --- /dev/null +++ b/desktop/src/app.js @@ -0,0 +1,876 @@ +'use strict'; +// append() soll null/false/Arrays wie h() behandeln (sonst erscheint der Text "null") +const _append = Element.prototype.append; +Element.prototype.append = function (...a) { return _append.apply(this, a.flat(Infinity).filter((x) => x != null && x !== false)); }; +const $app = document.getElementById('app'); +const state = { staff: null, meta: null, since: '', badge: 0, page: 'dashboard', timers: [] }; + +// ─── Helfer ────────────────────────────────────────────────────────────────── +function h(tag, attrs, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v === false || v == null) continue; + if (k === 'class') el.className = v; + else if (k === 'color') el.style.setProperty('background', v); + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'value') el.value = v; + else if (k === 'checked' || k === 'disabled' || k === 'selected') el[k] = !!v; + else el.setAttribute(k, v); + } + for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(String(kid))); + return el; +} +const clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); return el; }; +const can = (p) => state.staff && (state.staff.role === 'admin' || state.staff.permissions?.[p] === true); +const isAdmin = () => state.staff?.role === 'admin'; + +function toast(msg, error) { + const t = document.getElementById('toast'); + t.textContent = msg; t.className = 'toast' + (error ? ' error' : ''); + clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.add('hidden'), error ? 5000 : 2500); +} +async function api(path, opts = {}) { + const r = await window.deskhub.api({ path, ...opts }); + if (r.error) { + const down = r.error.code === 'NETWORK' || [502, 503, 504].includes(r.error.status); + if (down) setOffline(true); + const e = new Error(down ? 'Keine Verbindung zum Server. Es wird automatisch erneut versucht.' : r.error.message); + e.code = r.error.code; e.status = r.error.status; e.offline = down; throw e; + } + if (state.offline) setOffline(false); + return r.data; +} + +// ─── Verbindungsstatus & Systemhinweise (Banner unter der Kopfleiste) ─────── +function setOffline(on) { + if (state.offline === on) return; + state.offline = on; + clearTimeout(state.retryTimer); + if (on) { state.retryIn = 10; state.retryTimer = setTimeout(retryTick, 1000); } + else { toast('Verbindung wiederhergestellt'); if (state.staff) go(state.page, state.arg); } + paintBanners(); +} +async function retryNow() { + state.retryIn = 10; paintBanners(); + try { await api('me.php'); } catch { /* Banner bleibt, Zähler läuft weiter */ } +} +function retryTick() { + if (!state.offline) return; + state.retryIn -= 1; + if (state.retryIn <= 0) { retryNow().finally(() => { if (state.offline) state.retryTimer = setTimeout(retryTick, 1000); }); return; } + paintBanners(); state.retryTimer = setTimeout(retryTick, 1000); +} +function paintBanners() { + const box = document.getElementById('banners'); if (!box) return; + clear(box); + if (state.offline) { + box.append(h('div', { class: 'banner error' }, h('span', { class: 'grow' }, `Keine Verbindung zum Server – nächster Versuch in ${state.retryIn || 10} s. Bereits geladene Daten sind eventuell veraltet.`), + h('button', { class: 'btn ghost sm', onclick: retryNow }, 'Jetzt erneut versuchen'))); + } + for (const i of state.health || []) box.append(h('div', { class: 'banner ' + (i.level === 'error' ? 'error' : 'warn') }, h('span', { class: 'grow' }, (i.level === 'error' ? 'Störung: ' : 'Hinweis: ') + i.message))); +} +const post = (path, body) => api(path, { method: 'POST', body }); +async function run(fn, okMsg) { + try { const r = await fn(); if (okMsg) toast(okMsg); return r; } + catch (e) { toast(e.message, true); return undefined; } +} +const fmtDate = (s) => s ? new Date(s.replace(' ', 'T')).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; +const textToHtml = (t) => '

' + t.replace(/&/g, '&').replace(//g, '>').split(/\n{2,}/).map((p) => p.replace(/\n/g, '
')).join('

') + '

'; +const statusInfo = (v) => state.meta?.statuses.find((s) => s.value === v) || { label: v, color: '#64748b' }; + +function modal(title, bodyEl, buttons) { + const bg = h('div', { class: 'modal-bg' }); + const close = () => bg.remove(); + bg.append(h('div', { class: 'modal' }, h('h2', {}, title), bodyEl, + h('div', { class: 'row', style: null }, ...(buttons || []).map((b) => h('button', { class: 'btn ' + (b.cls || ''), onclick: () => b.fn(close) }, b.label)), + h('button', { class: 'btn ghost', onclick: close }, 'Abbrechen')))); + bg.addEventListener('mousedown', (e) => { if (e.target === bg) close(); }); + document.body.append(bg); + return close; +} + +// ─── Login ─────────────────────────────────────────────────────────────────── +async function showLogin(prefillUrl) { + clear($app); + document.body.classList.remove('in-app'); + const url = h('input', { placeholder: 'https://support.example.de', value: prefillUrl || localStorage.getItem('serverUrl') || '' }); + const email = h('input', { type: 'email', placeholder: 'mitarbeiter@firma.de' }); + const pw = h('input', { type: 'password' }); + const totp = h('input', { placeholder: '6-stelliger Code', maxlength: 6 }); + const totpRow = h('div', { class: 'hidden' }, h('label', {}, 'Authenticator-Code'), totp); + const err = h('div', { class: 'err' }); + const btn = h('button', { class: 'btn', type: 'submit' }, 'Anmelden'); + const form = h('form', {}, h('label', {}, 'Server-Adresse'), url, h('label', {}, 'E-Mail'), email, h('label', {}, 'Passwort'), pw, totpRow, err, h('div', { class: 'row' }, btn)); + form.addEventListener('submit', async (e) => { + e.preventDefault(); btn.disabled = true; err.textContent = ''; + const r = await window.deskhub.login({ serverUrl: url.value, email: email.value, password: pw.value, totpCode: totp.value }); + btn.disabled = false; + if (r.error) { err.textContent = r.error; return; } + if (r.requiresTotp) { totpRow.classList.remove('hidden'); totp.focus(); err.textContent = 'Bitte Authenticator-Code eingeben.'; return; } + localStorage.setItem('serverUrl', url.value); + state.serverUrl = (await window.deskhub.session())?.serverUrl || ''; + await startApp(r.staff, null, state.serverUrl); + }); + $app.append(h('div', { class: 'login' }, h('div', { class: 'card' }, h('h1', {}, 'DeskHub'), h('div', { class: 'muted' }, 'Mitarbeiter-Anmeldung'), form))); + (url.value ? email : url).focus(); +} + +// ─── App-Rahmen ────────────────────────────────────────────────────────────── +const adminSections = () => ADMIN.filter((s) => (s.admin ? isAdmin() : can(s.perm))); +const NAV = [['dashboard', 'home', 'Übersicht'], ['tickets', 'ticket', 'Tickets'], ['tasks', 'check', 'Aufgaben'], ['customers', 'users', 'Kunden'], ['chat', 'chat', 'Team-Chat']]; + +// Schlichte Linien-Icons (inline SVG, CSP-sicher über createElementNS) +const ICONS = { + home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10', + ticket: 'M3 9a2 2 0 002-2V5h14v2a2 2 0 002 2v2a2 2 0 00-2 2v2a2 2 0 002 2v2H5v-2a2 2 0 00-2-2v-2a2 2 0 002-2V9z M9 8v2 M9 14v2', + check: 'M4 12l5 5L20 6', + users: 'M16 11a4 4 0 10-8 0 4 4 0 008 0 M3 20c0-3.3 4-5 9-5s9 1.7 9 5', + chat: 'M4 5h16v11H9l-5 4V5z', + bubble: 'M21 12a8 8 0 01-11.6 7.1L4 20l1.1-4.6A8 8 0 1121 12z', + dot: 'M12 12m-4 0a4 4 0 108 0 4 4 0 10-8 0', + gear: 'M12 15a3 3 0 100-6 3 3 0 000 6z M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4.4l-.4 2.7a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4.4l.4-2.7a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z', + sliders: 'M4 6h10 M18 6h2 M4 12h4 M12 12h8 M4 18h12 M20 18h0 M14 4v4 M8 10v4 M16 16v4', + search: 'M11 4a7 7 0 100 14 7 7 0 000-14z M21 21l-4.3-4.3', + chart: 'M4 20V10 M10 20V4 M16 20v-7 M22 20H2', +}; +function icon(name) { + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); + for (const d of (ICONS[name] || ICONS.dot).split(' M').map((x, i) => (i ? 'M' + x : x))) { const path = document.createElementNS(NS, 'path'); path.setAttribute('d', d); svg.appendChild(path); } + return svg; +} +const initials = (name) => (name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join(''); + +// Schneller Start: Oberfläche sofort aus dem Zwischenspeicher aufbauen, Serverdaten danach im Hintergrund erneuern. +const metaKey = (url) => 'meta:' + url; +async function startApp(staff, cachedMeta, serverUrl) { + state.staff = staff; + state.since = ''; state.badge = 0; + if (!state.prefs) state.prefs = await window.deskhub.settings(); + applyTheme(); + const fresh = (async () => { + try { + const m = await api('metadata.php'); + const changed = !state.meta || JSON.stringify(m.staff.permissions) !== JSON.stringify(state.meta.staff.permissions) || m.liveChatEnabled !== state.meta.liveChatEnabled; + state.meta = m; state.staff = m.staff; state.health = m.health || []; + store.set(metaKey(`${serverUrl}:${staff.id}`), m); + return changed; + } catch (e) { toast(e.message, true); return false; } + })(); + if (cachedMeta) { + state.meta = cachedMeta; state.staff = cachedMeta.staff; + renderShell(); go('dashboard'); schedulePoll(0); + if (await fresh) { renderShell(); go(state.page, state.arg); } + } else { + await fresh; renderShell(); go('dashboard'); schedulePoll(0); + } +} + +// Design: "system" folgt der Windows-Einstellung (und reagiert live auf Wechsel), sonst fest Hell/Dunkel. +const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); +function applyTheme() { + const pref = state.prefs?.theme || 'system'; + document.documentElement.dataset.theme = pref === 'system' ? (darkQuery.matches ? 'dark' : 'light') : pref; +} +darkQuery.addEventListener('change', () => applyTheme()); + +function renderShell() { + clear($app); + document.body.classList.add('in-app'); + const items = [...NAV]; + if (state.meta?.liveChatEnabled) items.push(['livechat', 'bubble', 'Live-Chat']); + if (can('view_reports')) items.push(['reports', 'chart', 'Berichte']); + items.push(['availability', 'dot', 'Verfügbarkeit']); + if (adminSections().length) items.push(['admin', 'sliders', 'Verwaltung']); + const top = h('header', { class: 'topbar' }, + h('div', { class: 'brand' }, h('span', { class: 'logo' }, 'D'), 'DeskHub'), + can('create_ticket') ? h('button', { class: 'btn ok', onclick: () => go('newticket') }, 'Neues Ticket') : null, + h('button', { class: 'searchbtn', onclick: openPalette }, h('span', { class: 'nl' }, icon('search'), 'Ticket, Kunde oder Bereich suchen…'), h('kbd', {}, 'Strg K')), + h('div', { class: 'spacer' }), + h('div', { class: 'who' }, h('div', { class: 'avatar' }, initials(state.staff.name)), + h('div', {}, h('div', { class: 'name' }, state.staff.name), h('div', { class: 'role' }, state.staff.role === 'admin' ? 'Administrator' : 'Mitarbeiter'))), + h('button', { class: 'btn ghost sm', onclick: doLogout }, 'Abmelden')); + const side = h('nav', { class: 'side' }, + ...items.map(([k, ic, label]) => h('button', { class: 'nav', 'data-page': k, onclick: () => go(k) }, h('span', { class: 'nl' }, icon(ic), label), k === 'tickets' ? h('span', { class: 'badge hidden', id: 'badge' }) : null)), + h('div', { class: 'spacer' }), + h('button', { class: 'nav', 'data-page': 'settings', onclick: () => go('settings') }, h('span', { class: 'nl' }, icon('gear'), 'Einstellungen'))); + $app.append(h('div', { class: 'shell' }, top, side, h('div', { class: 'mainwrap' }, h('div', { id: 'banners' }), h('main', { class: 'main', id: 'main' })))); + paintBanners(); +} + +const PAGE_GROUP = { ticket: 'tickets', newticket: 'tickets' }; +const VIEWS_BY_PAGE = () => ({ dashboard: vDashboard, tickets: vTickets, ticket: vTicket, newticket: vNewTicket, tasks: vTasks, customers: vCustomers, + chat: vChat, reports: vReports, livechat: vLiveChat, availability: vAvailability, admin: vAdmin, settings: vSettings }); + +function go(page, arg) { + state.page = page; state.arg = arg; + for (const n of document.querySelectorAll('.nav')) n.classList.toggle('active', n.dataset.page === (PAGE_GROUP[page] || page)); + state.timers.filter((t) => t.page).forEach((t) => clearInterval(t.id)); + state.timers = state.timers.filter((t) => !t.page); + const main = clear(document.getElementById('main')); + main.scrollTop = 0; + const view = VIEWS_BY_PAGE()[page]; + if (!view) return; + Promise.resolve(view(main, arg)).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message))); +} +function every(ms, fn) { const id = setInterval(fn, ms); state.timers.push({ id, page: true }); } + +async function doLogout() { + clearTimeout(state.pollTimer); + await window.deskhub.logout(); + state.timers.forEach((t) => clearInterval(t.id ?? t)); state.timers = []; state.staff = null; + window.deskhub.badge(0, null); + showLogin(); +} + +// ─── Benachrichtigungen & Zähler ──────────────────────────────────────────── +function badgeImage(n) { + const c = document.createElement('canvas'); c.width = c.height = 32; + const g = c.getContext('2d'); + g.fillStyle = '#ef4444'; g.beginPath(); g.arc(16, 16, 15, 0, Math.PI * 2); g.fill(); + g.fillStyle = '#fff'; g.font = 'bold 20px Segoe UI, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; + g.fillText(n > 99 ? '99+' : String(n), 16, 17); + return c.toDataURL('image/png'); +} +function paintBadge() { + const b = document.getElementById('badge'); + if (b) { b.textContent = state.badge; b.classList.toggle('hidden', !state.badge); } + window.deskhub.badge(state.badge, state.badge ? badgeImage(state.badge) : null); +} +function resetBadge() { state.badge = 0; paintBadge(); } + +function schedulePoll(delay) { + clearTimeout(state.pollTimer); + const seconds = Math.max(15, Number(state.prefs?.pollSeconds) || 60); + state.pollTimer = setTimeout(async () => { await pollActivity(); if (state.staff) schedulePoll(seconds * 1000); }, delay ?? seconds * 1000); +} + +async function pollActivity() { + try { + const a = await api('activity.php', { query: { since: state.since } }); + if (state.since && (a.newReplies || a.newUnassigned)) { + state.badge += a.newReplies + a.newUnassigned; + if (state.prefs?.notifications !== false) { + const parts = []; + if (a.newReplies) parts.push(`${a.newReplies} Aktualisierung(en) an deinen Tickets`); + if (a.newUnassigned) parts.push(`${a.newUnassigned} neue(s) nicht zugewiesene(s) Ticket(s)`); + window.deskhub.notify('DeskHub', parts.join(', ')); + } + paintBadge(); + } + state.since = a.serverTime; + state.health = a.health || []; paintBanners(); + } catch {} +} + +// ─── Übersicht ─────────────────────────────────────────────────────────────── +const dueClass = (t) => (t.isOverdue ? 'over' : 'muted'); +function ticketTable(items, opts = {}) { + if (!items.length) return h('div', { class: 'panel muted' }, 'Keine Tickets gefunden.'); + const selected = opts.selected; + const allBox = selected ? h('input', { type: 'checkbox', onchange: (e) => { items.forEach((t) => (e.target.checked ? selected.add(t.number) : selected.delete(t.number))); opts.onSelect?.(); for (const c of document.querySelectorAll('.rowcheck')) c.checked = e.target.checked; } }) : null; + return h('div', { class: 'panel flush' }, h('table', { class: 'tickets' }, + h('tr', {}, selected ? h('th', { class: 'chk' }, allBox) : null, h('th', {}, 'Nr.'), h('th', {}, 'Betreff'), h('th', {}, 'Kunde'), h('th', {}, 'Status'), h('th', {}, 'Priorität'), h('th', {}, 'Zugewiesen'), h('th', {}, 'Fällig'), h('th', {}, 'Aktualisiert')), + ...items.map((t) => h('tr', { class: 'click' + (t.isOverdue ? ' overdue' : ''), onclick: () => go('ticket', t.number) }, + selected ? h('td', { class: 'chk', onclick: (e) => e.stopPropagation() }, h('input', { type: 'checkbox', class: 'rowcheck', checked: selected.has(t.number), onchange: (e) => { e.target.checked ? selected.add(t.number) : selected.delete(t.number); e.target.closest('tr').classList.toggle('sel', e.target.checked); opts.onSelect?.(); } })) : null, + h('td', { class: 'nowrap' }, t.number), h('td', {}, (t.isSpam ? '🚫 ' : '') + t.subject), h('td', {}, t.requesterName), + h('td', { class: 'nowrap' }, h('span', { class: 'pill', color: statusInfo(t.status).color }, statusInfo(t.status).label)), + h('td', { class: 'nowrap' }, h('span', { class: 'dot', color: t.priority.color }), t.priority.name), + h('td', {}, t.assignedStaff?.name || '—'), h('td', { class: dueClass(t) + ' nowrap' }, t.dueLabel || ''), h('td', { class: 'muted nowrap' }, fmtDate(t.updatedAt)))))); +} + +async function vDashboard(main) { + const body = h('div'); + const hour = new Date().getHours(), greet = hour < 11 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend'; + main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, `${greet}, ${state.staff.name.split(' ')[0]}`), + h('div', { class: 'sub' }, new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }))), + h('button', { class: 'btn ghost sm', onclick: () => load() }, 'Aktualisieren')), body); + const load = async () => { + const d = await run(() => api('dashboard.php')); if (!d) return; + const stat = (n, label, q, cls) => h('div', { class: 'stat ' + (cls || '') + (q ? ' link' : ''), onclick: q ? () => go('tickets', q) : null }, h('div', { class: 'n' }, n), h('div', { class: 'muted' }, label)); + clear(body).append(h('div', { class: 'cards' }, + stat(d.myOpen, 'Meine offenen', { view: 'mine' }), stat(d.overdue, 'Überfällig', { view: 'overdue' }, d.overdue ? 'bad' : ''), + stat(d.unassigned, 'Nicht zugewiesen', { view: 'unassigned' }, d.unassigned ? 'warn' : ''), stat(d.unanswered, 'Unbeantwortet', { view: 'active' }), + stat(d.resolvedToday, 'Heute gelöst', null, 'ok'), d.slaPercent != null ? stat(Math.round(d.slaPercent) + '%', 'SLA-Quote', null, d.slaPercent < 80 ? 'warn' : 'ok') : null), + h('div', { class: 'split2' }, + h('div', {}, h('h3', {}, 'Zuletzt aktualisiert'), ticketTable(d.recent)), + d.departments?.length ? h('div', {}, h('h3', {}, 'Abteilungen'), h('div', { class: 'panel' }, ...d.departments.map((x) => { + const max = Math.max(1, ...d.departments.map((y) => y.open)); + return h('div', { class: 'bar' }, h('div', { class: 'row' }, h('span', { class: 'grow' }, x.name), h('span', { class: 'muted' }, `${x.open} offen`), x.overdue ? h('span', { class: 'over' }, `${x.overdue} überfällig`) : null), + h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round((x.open / max) * 100) }))); + }))) : null)); + for (const f of body.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%'); + }; + await load(); + every(30000, load); +} + +// ─── Tickets ───────────────────────────────────────────────────────────────── +const VIEWS = { + active: ['Alle aktiven', { status: 'open,pending,answered,on_hold', assigned: '', sla: '' }], + mine: ['Meine', { status: 'open,pending,answered,on_hold', assigned: 'me', sla: '' }], + unassigned: ['Nicht zugewiesen', { status: 'open,pending,answered,on_hold', assigned: 'unassigned', sla: '' }], + overdue: ['Überfällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'overdue' }], + atrisk: ['Bald fällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'at_risk' }], + closed: ['Erledigt', { status: 'resolved,closed', assigned: '', sla: '' }], + all: ['Alle', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '' }], +}; +const store = { + get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, + set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, + del(k) { try { localStorage.removeItem(k); } catch {} }, +}; + +async function vTickets(main, preset) { + const saved = store.get('ticketFilter', {}); + const f = { view: 'active', q: '', departmentId: '', priorityId: '', page: 1, ...saved, ...(preset || {}) }; + if (!VIEWS[f.view]) f.view = 'active'; + const selected = new Set(); + const list = h('div'), bulk = h('div', { class: 'bulk hidden' }); + const custom = {}; // gespeicherte Ansichten (Serverseite), getrennt von den festen Reitern + const filt = () => ({ ...((VIEWS[f.view] || custom[f.view] || ['', {}])[1]), q: f.q, departmentId: f.departmentId, priorityId: f.priorityId, page: f.page }); + const persist = () => store.set('ticketFilter', { view: f.view, departmentId: f.departmentId, priorityId: f.priorityId }); + const tabs = h('div', { class: 'tabs wrap' }); + const viewsBar = h('div', { class: 'viewsbar' }); + const applyView = (q) => { + const k = 'q' + q.id; const { q: text = '', departmentId = '', priorityId = '', ...rest } = q.filters || {}; + custom[k] = [q.name, { status: '', assigned: '', sla: '', ...rest }]; + Object.assign(f, { view: k, q: text, departmentId, priorityId, page: 1 }); selected.clear(); + search.value = text; paintTabs(); paintViews(); load(); + }; + const saveView = () => { + const name = h('input', { placeholder: 'z. B. Meine überfälligen' }), shared = h('input', { type: 'checkbox' }); + modal('Ansicht speichern', h('div', {}, h('p', { class: 'muted' }, 'Speichert die aktuellen Filter (Reiter, Suche, Abteilung, Priorität).'), h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), + [{ label: 'Speichern', fn: async (close) => { + const { page, ...filters } = filt(); + if (await run(() => post('queues.php', { action: 'save', name: name.value, isShared: shared.checked, filters }), 'Ansicht gespeichert')) { close(); paintViews(); } + } }]); + }; + const paintViews = async () => { + const r = await api('queues.php').catch(() => null); const items = r?.items || []; + clear(viewsBar).append(items.length ? h('span', { class: 'muted viewlabel' }, 'Ansichten') : null, + ...items.map((q) => h('span', { class: 'viewchip' + (f.view === 'q' + q.id ? ' active' : '') }, + h('button', { class: 'viewbtn', title: q.mine ? 'Meine Ansicht' : `Geteilt von ${q.ownerName || ''}`, onclick: () => applyView(q) }, q.name + (q.isShared ? ' ⇄' : '')), + q.mine || isAdmin() ? h('button', { class: 'viewx', title: 'Ansicht löschen', onclick: () => confirm('Ansicht löschen?') && run(async () => { await post('queues.php', { action: 'delete', id: q.id }); paintViews(); }) }, '✕') : null)), + h('button', { class: 'btn ghost sm', onclick: saveView }, '+ Ansicht speichern')); + }; + const paintTabs = () => { clear(tabs).append(...Object.entries(VIEWS).map(([k, [label]]) => h('button', { class: 'tab' + (f.view === k ? ' active' : ''), onclick: () => { f.view = k; f.page = 1; selected.clear(); persist(); paintTabs(); load(); } }, label))); }; + + const paintBulk = () => { + bulk.classList.toggle('hidden', selected.size === 0); clear(bulk); + if (!selected.size) return; + const act = async (patch, label) => { + let ok = 0; const fails = []; + for (const number of [...selected]) { try { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); ok++; } catch (e) { fails.push(`${number}: ${e.message}`); } } + toast(`${label}: ${ok} Ticket(s)` + (fails.length ? ` · ${fails.length} Fehler (${fails[0]})` : ''), !!fails.length); + selected.clear(); load(); + }; + const st = h('select', { onchange: (e) => { if (e.target.value) act({ status: e.target.value }, 'Status gesetzt'); } }, h('option', { value: '' }, 'Status setzen…'), ...state.meta.statuses.map((s) => h('option', { value: s.value }, s.label))); + const as = h('select', { onchange: (e) => { if (e.target.value !== '-') act({ assignedStaffId: e.target.value === '' ? null : +e.target.value }, 'Zugewiesen'); } }, h('option', { value: '-' }, 'Zuweisen…'), h('option', { value: '' }, '— niemand —'), ...state.meta.agents.map((a) => h('option', { value: a.id }, a.name))); + bulk.append(h('b', {}, `${selected.size} ausgewählt`), can('reply_ticket') || can('close_ticket') ? st : null, can('assign_ticket') ? as : null, + can('assign_ticket') ? h('button', { class: 'btn sm', onclick: () => act({ assignedStaffId: state.staff.id }, 'Mir zugewiesen') }, 'Mir zuweisen') : null, + h('button', { class: 'btn ghost sm', onclick: () => { selected.clear(); paintBulk(); for (const c of document.querySelectorAll('.rowcheck')) c.checked = false; } }, 'Auswahl aufheben')); + }; + + const load = async (silent) => { + const r = await (silent ? api('tickets.php', { query: filt() }).catch(() => null) : run(() => api('tickets.php', { query: filt() }))); + if (!r) return; + clear(list).append(ticketTable(r.items, { selected, onSelect: paintBulk }), h('div', { class: 'row pager' }, + h('button', { class: 'btn ghost sm', disabled: f.page <= 1, onclick: () => { f.page--; load(); } }, '‹ Zurück'), + h('span', { class: 'muted' }, `Seite ${r.page} · ${r.total} Tickets`), + h('button', { class: 'btn ghost sm', disabled: !r.hasMore, onclick: () => { f.page++; load(); } }, 'Weiter ›'))); + paintBulk(); + }; + const sel = (key, opts) => h('select', { onchange: (e) => { f[key] = e.target.value; f.page = 1; persist(); load(); } }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(f[key]) === String(v) }, l))); + const search = h('input', { placeholder: 'Suchen (Betreff, Nummer, Kunde, Text)… Strg+F', value: f.q, class: 'search', onkeydown: (e) => { if (e.key === 'Enter') { f.q = e.target.value; f.page = 1; load(); } } }); + search.id = 'ticket-search'; + main.append(h('div', { class: 'row' }, h('h2', { class: 'grow' }, 'Tickets'), h('button', { class: 'btn ghost sm', onclick: () => load() }, 'Aktualisieren')), + tabs, viewsBar, h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => { f.q = search.value; f.page = 1; load(); } }, 'Suchen'), + sel('departmentId', [['', 'Alle Abteilungen'], ...state.meta.departments.map((d) => [d.id, d.name])]), sel('priorityId', [['', 'Alle Prioritäten'], ...state.meta.priorities.map((p) => [p.id, p.name])])), + bulk, list); + paintTabs(); paintViews(); resetBadge(); + await load(); + every(30000, () => { if (!selected.size && !document.querySelector('.modal-bg')) load(true); }); +} + +// ─── Ticket-Detail ─────────────────────────────────────────────────────────── +async function vTicket(main, number) { + let t; + try { t = (await api('ticket.php', { query: { number } })).ticket; } + catch (e) { main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Zurück'), h('div', { class: 'warnbox' }, e.message)); return; } + + // Rechte gelten pro Abteilung des Tickets; ältere Server liefern sie nicht -> globale Rechte + const can = (p) => (t.permissions ? t.permissions[p] === true : (state.staff.role === 'admin' || state.staff.permissions?.[p] === true)); + const reload = () => go('ticket', number); + const update = (patch, msg) => run(async () => { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); reload(); }, msg); + const mkSelect = (opts, val, onchange, disabled) => h('select', { disabled, onchange: (e) => onchange(e.target.value) }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(v) === String(val) }, l))); + const mine = t.assignedStaff?.id === state.staff.id; + const closed = ['resolved', 'closed'].includes(t.status); + + const statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => update({ status: v }, 'Status geändert'), !can('reply_ticket') && !can('close_ticket')); + const assignSel = mkSelect([['', '— niemand —'], ...state.meta.agents.map((a) => [a.id, a.name])], t.assignedStaff?.id ?? '', (v) => update({ assignedStaffId: v === '' ? null : Number(v) }, 'Zuweisung geändert'), !can('assign_ticket')); + + // Antwort-Editor mit Entwurf + let mode = can('reply_ticket') ? 'reply' : 'note'; + const draftKey = (m) => `draft:${number}:${m}`; + const ta = h('textarea', { placeholder: 'Nachricht… (Strg+Enter zum Senden)', class: 'editor' }); + const loadDraft = () => { ta.value = store.get(draftKey(mode), ''); }; + ta.addEventListener('input', () => ta.value ? store.set(draftKey(mode), ta.value) : store.del(draftKey(mode))); + const tabs = h('div', { class: 'tabs' }); + const sendStatus = mkSelect([['', 'Status: automatisch'], ...state.meta.statuses.map((s) => [s.value, 'Setzen auf: ' + s.label])], '', () => {}); + let cannedList = []; + const canned = h('select', { onchange: (e) => { const c = cannedList.find((x) => String(x.id) === e.target.value); if (c) { ta.value += (ta.value ? '\n' : '') + c.body.replace(//gi, '\n').replace(/<\/p>/gi, '\n').replace(/<[^>]+>/g, '').trim(); ta.dispatchEvent(new Event('input')); ta.focus(); } e.target.value = ''; } }, h('option', { value: '' }, 'Textbaustein…')); + api('canned_responses.php').then((r) => { cannedList = r.items; cannedList.forEach((c) => canned.append(h('option', { value: c.id }, c.title))); }).catch(() => {}); + const sendBtn = h('button', { class: 'btn' }, 'Senden'); + const paintTabs = () => { + clear(tabs); + if (can('reply_ticket')) tabs.append(h('button', { class: 'tab' + (mode === 'reply' ? ' active' : ''), onclick: () => { mode = 'reply'; loadDraft(); paintTabs(); } }, 'Antwort an Kunden')); + if (can('post_note')) tabs.append(h('button', { class: 'tab' + (mode === 'note' ? ' active' : ''), onclick: () => { mode = 'note'; loadDraft(); paintTabs(); } }, 'Interne Notiz')); + sendStatus.classList.toggle('hidden', mode !== 'reply'); + sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern'; + ta.classList.toggle('noteedit', mode === 'note'); + }; + paintTabs(); loadDraft(); + const send = async () => { + if (!ta.value.trim() || sendBtn.disabled) return; + if (mode === 'reply' && !confirm('Antwort jetzt an den Kunden senden?')) return; + sendBtn.disabled = true; + const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); + sendBtn.disabled = false; + if (ok) { store.del(draftKey(mode)); reload(); } + }; + sendBtn.addEventListener('click', send); + ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); } }); + + const quote = (m) => { ta.value += (ta.value ? '\n\n' : '') + m.message.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'; ta.dispatchEvent(new Event('input')); ta.focus(); }; + const threadsEl = h('div', { class: 'threads' }, ...t.threads.map((m) => { + const cust = m.authorType !== 'staff' && !m.isInternalNote; + return h('div', { class: 'thread ' + (m.isInternalNote ? 'note' : cust ? 'cust' : 'staff') }, + h('div', { class: 'head' }, h('span', { class: 'who' }, h('span', { class: 'avatar sm' }, initials(m.authorName)), h('b', {}, m.authorName), + h('span', { class: 'kind' }, m.isInternalNote ? 'Interne Notiz' : cust ? 'Kunde' : 'Antwort')), h('span', {}, fmtDate(m.createdAt))), + h('div', { class: 'body' }, m.message), + m.attachments.length ? h('div', { class: 'muted att' }, '📎 ' + m.attachments.map((a) => `${a.name} (${Math.round(a.size / 1024)} KB)`).join(', ') + ' – Download nur im Web') : null, + ...m.comments.map((c) => h('div', { class: 'comment' }, h('b', {}, c.staff.name + ': '), c.message)), + cust && can('reply_ticket') ? h('div', { class: 'tools' }, h('button', { class: 'btn ghost sm', onclick: () => { mode = 'reply'; paintTabs(); loadDraft(); quote(m); } }, 'Zitieren')) : null); + })); + + const spamBtn = can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => run(async () => { await post('ticket_spam.php', { number, action: t.isSpam ? 'unspam' : 'spam' }); reload(); }, t.isSpam ? 'Kein Spam' : 'Als Spam markiert') }, t.isSpam ? 'Kein Spam' : 'Spam') : null; + const others = h('div', { class: 'muted' }, 'Lade…'); + if (t.requesterEmail) api('customer.php', { query: { email: t.requesterEmail } }).then((r) => { + const rest = r.tickets.filter((x) => x.number !== number).slice(0, 6); + clear(others).append(...(rest.length ? rest.map((x) => h('div', { class: 'mini', onclick: () => go('ticket', x.number) }, h('span', { class: 'dot', color: statusInfo(x.status).color }), `${x.number} · ${x.subject}`)) : ['Keine weiteren Tickets.'])); + }).catch(() => clear(others).append('—')); + + const actions = h('div', { class: 'tkactions' }, + can('assign_ticket') && !mine ? h('button', { class: 'btn sm', onclick: () => update({ assignedStaffId: state.staff.id }, 'Dir zugewiesen') }, 'Mir zuweisen') : null, + !closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => update({ status: 'resolved' }, 'Gelöst') }, 'Lösen') : null, + !closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => update({ status: 'closed' }, 'Geschlossen') }, 'Schließen') : null, + closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null, + spamBtn, + h('button', { class: 'btn ghost sm', title: 'Ticketnummer kopieren', onclick: () => { navigator.clipboard?.writeText(t.number); toast('Nummer kopiert'); } }, 'Nummer kopieren'), + h('button', { class: 'btn ghost sm', onclick: reload }, 'Aktualisieren')); + const st = statusInfo(t.status); + const prop = (label, ...kids) => [h('dt', {}, label), h('dd', {}, ...kids)]; + + main.append( + h('div', { class: 'tkhead' }, + h('div', { class: 'tkhead-top' }, h('button', { class: 'btn ghost sm', onclick: () => go('tickets') }, 'Zurück zu Tickets'), h('span', { class: 'chip' }, '#' + t.number), + h('span', { class: 'pill', color: st.color }, st.label), t.isOverdue ? h('span', { class: 'pill', color: '#d63b3b' }, 'Überfällig') : null, t.isSpam ? h('span', { class: 'pill', color: '#667384' }, 'Spam') : null), + h('h2', {}, t.subject), + h('div', { class: 'sub' }, `${t.requesterName} · ${t.department.name} · erstellt ${fmtDate(t.createdAt)}`), + actions), + t.lockedByOtherName ? h('div', { class: 'warnbox' }, `Dieser Chat wird gerade von „${t.lockedByOtherName}“ bearbeitet – nur Ansicht.`) : null, + h('div', { class: 'split' }, + h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null), + h('div', {}, + h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Eigenschaften'), + h('dl', { class: 'props' }, + ...prop('Status', statusSel), ...prop('Zugewiesen an', assignSel), + ...prop('Priorität', h('span', { class: 'dot', color: t.priority.color }), t.priority.name), + ...prop('Abteilung', t.department.name), + ...prop('Erstellt', fmtDate(t.createdAt)), + ...(t.resolutionDueAt ? prop('Lösung fällig', h('span', { class: t.isOverdue ? 'over' : '' }, fmtDate(t.resolutionDueAt))) : []))), + h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Kunde'), + h('div', { class: 'custcard' }, h('span', { class: 'avatar' }, initials(t.requesterName)), h('div', {}, h('b', {}, t.requesterName), h('div', { class: 'muted' }, t.requesterEmail))), + t.requesterEmail ? h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => go('customers', t.requesterEmail) }, 'Kundenprofil')) : null), + h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Weitere Tickets des Kunden'), others)))); + + // Neue Nachrichten von Kunden/Kollegen erkennen, ohne den Entwurf zu verlieren + every(45000, async () => { + try { + const n = (await api('ticket.php', { query: { number } })).ticket; + if (n.threads.length !== t.threads.length || n.status !== t.status) { toast('Ticket wurde aktualisiert'); reload(); } + } catch {} + }); + threadsEl.lastElementChild?.scrollIntoView({ block: 'nearest' }); +} + +async function vNewTicket(main) { + if (!can('create_ticket')) { main.append(h('div', { class: 'warnbox' }, 'Dir fehlt das Recht „Tickets erstellen“.')); return; } + const m = state.meta; + const f = { name: h('input'), email: h('input', { type: 'email' }), subject: h('input'), message: h('textarea'), + dept: h('select', {}, ...m.departments.map((d) => h('option', { value: d.id }, d.name))), + prio: h('select', {}, ...m.priorities.map((p) => h('option', { value: p.id }, p.name))), + agent: h('select', {}, h('option', { value: '' }, '— niemand —'), ...m.agents.map((a) => h('option', { value: a.id }, a.name))), + notify: h('input', { type: 'checkbox', checked: true }) }; + main.append(h('button', { class: 'btn ghost', onclick: () => go('tickets') }, '‹ Tickets'), h('h2', {}, 'Neues Ticket'), + h('div', { class: 'panel' }, + h('label', {}, 'Name des Kunden'), f.name, h('label', {}, 'E-Mail des Kunden (optional)'), f.email, h('label', {}, 'Betreff'), f.subject, + h('label', {}, 'Beschreibung'), f.message, h('label', {}, 'Abteilung'), f.dept, h('label', {}, 'Priorität'), f.prio, + h('label', {}, 'Zuweisen an'), f.agent, h('label', { class: 'check' }, f.notify, 'Kunden per E-Mail benachrichtigen'), + h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => { + const r = await run(() => post('ticket_create.php', { requesterName: f.name.value, requesterEmail: f.email.value, subject: f.subject.value, + message: textToHtml(f.message.value), departmentId: +f.dept.value, priorityId: +f.prio.value, assignedStaffId: f.agent.value ? +f.agent.value : null, notifyCustomer: f.notify.checked }), 'Ticket erstellt'); + if (r) go('ticket', r.ticket.number); + } }, 'Ticket erstellen')))); +} + +// ─── Aufgaben ──────────────────────────────────────────────────────────────── +async function vTasks(main) { + const body = h('div'); + main.append(h('div', { class: 'row' }, h('h2', { class: 'grow' }, 'Aufgaben'), h('button', { class: 'btn', onclick: () => newTask() }, '+ Aufgabe')), body); + let staffOpts = []; + const load = async () => { + const r = await api('tasks.php', { query: { filter: 'all' } }).catch((e) => { toast(e.message, true); return null; }); + if (!r) return; staffOpts = r.staffOptions; + clear(body).append(h('div', { class: 'panel' }, r.items.length ? h('table', {}, h('tr', {}, h('th', {}, ''), h('th', {}, 'Aufgabe'), h('th', {}, 'Zuständig'), h('th', {}, 'Fällig'), h('th', {}, '')), + ...r.items.map((t) => h('tr', {}, h('td', {}, h('input', { type: 'checkbox', checked: t.status === 'done', onchange: (e) => run(async () => { await post('tasks.php', { action: 'update_status', id: t.id, status: e.target.checked ? 'done' : 'open' }); load(); }) })), + h('td', {}, h('b', {}, t.title), t.description ? h('div', { class: 'muted' }, t.description) : null, t.ticketNumber ? h('a', { href: '#', onclick: (e) => { e.preventDefault(); go('ticket', t.ticketNumber); } }, ' ' + t.ticketNumber) : null), + h('td', {}, t.assignedName || '—'), h('td', {}, fmtDate(t.dueAt)), + h('td', {}, h('button', { class: 'btn danger sm', onclick: () => confirm('Aufgabe löschen?') && run(async () => { await post('tasks.php', { action: 'delete', id: t.id }); load(); }) }, '✕'))))) : h('div', { class: 'muted' }, 'Keine Aufgaben.'))); + }; + function newTask() { + const title = h('input'), desc = h('textarea'), who = h('select', {}, h('option', { value: '' }, '— niemand —'), ...staffOpts.map((s) => h('option', { value: s.id }, s.name))), due = h('input', { type: 'datetime-local' }); + modal('Neue Aufgabe', h('div', {}, h('label', {}, 'Titel'), title, h('label', {}, 'Beschreibung'), desc, h('label', {}, 'Zuständig'), who, h('label', {}, 'Fällig'), due), + [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('tasks.php', { action: 'create', title: title.value, description: desc.value || null, assignedToId: who.value ? +who.value : null, dueAt: due.value ? due.value.replace('T', ' ') + ':00' : null }))) { close(); load(); } } }]); + } + load(); +} + +// ─── Kunden ────────────────────────────────────────────────────────────────── +async function vCustomers(main, email) { + const body = h('div'); + const search = h('input', { placeholder: 'Name oder E-Mail suchen…', value: email || '', onkeydown: (e) => e.key === 'Enter' && load() }); + main.append(h('h2', {}, 'Kunden'), h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => load() }, 'Suchen')), body); + const load = async () => { + const r = await run(() => api('customers.php', { query: { q: search.value } })); if (!r) return; + clear(body).append(h('div', { class: 'panel' }, h('table', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'E-Mail'), h('th', {}, 'Organisation'), h('th', {}, 'Tickets')), + ...r.items.map((c) => h('tr', { class: 'click', onclick: () => open(c) }, h('td', {}, c.name + (c.isGuest ? ' (Gast)' : '')), h('td', {}, c.email), h('td', {}, c.organizationName || ''), h('td', {}, c.ticketCount)))))); + }; + const open = async (c) => { + const r = await run(() => api('customer.php', { query: c.id != null ? { userId: c.id } : { email: c.email } })); if (!r) return; + clear(body).append(h('button', { class: 'btn ghost', onclick: load }, '‹ Liste'), h('h3', {}, r.customer.name), h('div', { class: 'muted' }, r.customer.email), ticketTable(r.tickets)); + }; + load(); +} + +// ─── Team-Chat ─────────────────────────────────────────────────────────────── +async function vChat(main) { + const list = h('div', { class: 'chatlist' }), box = h('div', { class: 'chatbox' }, h('div', { class: 'msgs muted' }, 'Kollegen auswählen')); + main.append(h('h2', {}, 'Team-Chat'), h('div', { class: 'chatwrap' }, list, box)); + let current = null; + const loadContacts = async () => { + const r = await api('chat.php').catch(() => null); if (!r) return; + clear(list).append(...r.contacts.map((c) => h('div', { class: 'item' + (current === c.id ? ' active' : ''), onclick: () => openChat(c.id) }, c.name, c.unreadCount ? h('span', { class: 'badge' }, c.unreadCount) : null))); + }; + const openChat = async (id) => { + current = id; + const r = await run(() => api('chat.php', { query: { with: id } })); if (!r) return; + const msgs = h('div', { class: 'msgs' }, ...r.messages.map((m) => h('div', { class: 'msg' + (m.fromStaffId === state.staff.id ? ' mine' : '') }, h('small', {}, `${m.fromName} · ${fmtDate(m.createdAt)}`), m.message))); + const inp = h('input', { placeholder: 'Nachricht…', onkeydown: (e) => e.key === 'Enter' && send() }); + const send = async () => { if (!inp.value.trim()) return; const t = inp.value; inp.value = ''; if (await run(() => post('chat.php', { action: 'send', toId: id, message: t }))) openChat(id); }; + clear(box).append(h('div', { class: 'msgs-title', style: null }), msgs, h('div', { class: 'chatinput' }, inp, h('button', { class: 'btn', onclick: send }, 'Senden'))); + msgs.scrollTop = msgs.scrollHeight; inp.focus(); loadContacts(); + }; + await loadContacts(); + every(15000, () => { loadContacts(); }); +} + +// ─── Live-Chat ─────────────────────────────────────────────────────────────── +async function vLiveChat(main) { + const list = h('div', { class: 'chatlist' }), box = h('div', { class: 'chatbox' }, h('div', { class: 'msgs muted' }, 'Chat auswählen')); + main.append(h('h2', {}, 'Live-Chat (Kunden)'), h('div', { class: 'chatwrap' }, list, box)); + let cur = null; + const loadList = async () => { + const r = await api('live_chat.php', { query: { action: 'list' } }).catch(() => null); if (!r) return; + clear(list).append(...(r.items.length ? r.items.map((c) => h('div', { class: 'item' + (cur === c.number ? ' active' : ''), onclick: () => openChat(c.number) }, h('span', {}, c.name, h('div', { class: 'muted' }, c.preview)), c.needsReply ? h('span', { class: 'badge' }, '!') : null)) : [h('div', { class: 'item muted' }, 'Keine aktiven Chats')])); + }; + const openChat = async (number) => { + cur = number; + const r = await run(() => api('live_chat.php', { query: { action: 'messages', number, since: 0 } })); if (!r) return; + const msgs = h('div', { class: 'msgs' }, ...r.messages.map((m) => h('div', { class: 'msg' + (m.authorType === 'staff' ? ' mine' : '') }, h('small', {}, `${m.authorName || r.requesterName} · ${fmtDate(m.createdAt)}`), m.message))); + const inp = h('input', { placeholder: 'Antwort…', disabled: !!r.lockedByOtherName, onkeydown: (e) => e.key === 'Enter' && send() }); + const send = async () => { if (!inp.value.trim()) return; const t = inp.value; inp.value = ''; if (await run(() => post('live_chat.php', { action: 'send', number, message: t }))) openChat(number); }; + clear(box).append(r.lockedByOtherName ? h('div', { class: 'warnbox' }, `Wird gerade von ${r.lockedByOtherName} bearbeitet.`) : null, + h('div', { class: 'row' }, h('b', {}, `${r.requesterName} (${r.departmentName})`), h('button', { class: 'btn danger sm', onclick: () => confirm('Chat beenden?') && run(async () => { await post('live_chat.php', { action: 'end', number }); cur = null; clear(box); loadList(); }) }, 'Chat beenden')), + msgs, h('div', { class: 'chatinput' }, inp, h('button', { class: 'btn', disabled: !!r.lockedByOtherName, onclick: send }, 'Senden'))); + msgs.scrollTop = msgs.scrollHeight; + }; + await loadList(); + every(5000, () => { loadList(); if (cur && !document.querySelector('.chatinput input')?.value) openChat(cur); }); +} + +// ─── Berichte (Bericht-Baukasten) ──────────────────────────────────────────── +async function vReports(main) { + const cfg = { dimension: 'department', metrics: ['total', 'open', 'resolved'], from: '', to: '', sort: 'label', desc: false, filters: {} }; + const out = h('div'), controls = h('div', { class: 'panel' }), savedBar = h('div', { class: 'viewsbar' }); + let meta = null; + const fmt = (v, unit) => (v == null ? '–' : unit === '%' ? `${String(v).replace('.', ',')} %` : unit === 'Std' ? `${String(v).replace('.', ',')} Std` : String(v)); + const query = (extra = {}) => ({ dimension: cfg.dimension, metrics: cfg.metrics.join(','), from: cfg.from, to: cfg.to, sort: cfg.sort, desc: cfg.desc ? 1 : '', ...cfg.filters, ...extra }); + const run_ = async (extra) => { + const r = await run(() => api('report_builder.php', { query: query(extra) })); if (!r) return; + meta = r; Object.assign(cfg, { dimension: r.config.dimension, metrics: r.config.metrics, from: r.config.from, to: r.config.to, sort: r.config.sort, desc: r.config.desc }); + paintControls(); paintSaved(); + const max = {}; for (const c of r.columns.slice(1)) max[c.key] = Math.max(1, ...r.rows.map((x) => Number(x[c.key]) || 0)); + clear(out).append(h('div', { class: 'panel flush' }, r.rows.length ? h('table', {}, + h('tr', {}, ...r.columns.map((c) => h('th', {}, c.label))), + ...r.rows.map((row) => h('tr', {}, h('td', {}, h('b', {}, row.label)), ...r.columns.slice(1).map((c) => h('td', {}, fmt(row[c.key], c.unit), c.unit === '' && row[c.key] != null ? h('div', { class: 'track' }, h('div', { class: 'fill', 'data-w': Math.round((row[c.key] / max[c.key]) * 100) })) : null)))), + h('tr', { class: 'totalrow' }, h('td', {}, 'Gesamt'), ...r.columns.slice(1).map((c) => h('td', {}, fmt(r.totals[c.key], c.unit))))) : h('div', { class: 'muted pad' }, 'Keine Tickets im gewählten Zeitraum.'))); + for (const f of out.querySelectorAll('.fill')) f.style.setProperty('width', f.dataset.w + '%'); + }; + const paintSaved = () => clear(savedBar).append(meta?.saved?.length ? h('span', { class: 'muted viewlabel' }, 'Gespeichert') : null, + ...(meta?.saved || []).map((s) => h('span', { class: 'viewchip' }, h('button', { class: 'viewbtn', onclick: () => run_({ saved: s.id }) }, s.name + (s.isShared ? ' ⇄' : '')), + s.mine || isAdmin() ? h('button', { class: 'viewx', title: 'Löschen', onclick: () => confirm('Bericht löschen?') && run(async () => { await post('report_builder.php', { action: 'delete', id: s.id }); run_(); }) }, '✕') : null))); + const paintControls = () => { + const dim = h('select', { onchange: (e) => { cfg.dimension = e.target.value; } }, ...(meta?.dimensions || []).map((d) => h('option', { value: d.key, selected: d.key === cfg.dimension }, d.label))); + const from = h('input', { type: 'date', value: cfg.from, onchange: (e) => { cfg.from = e.target.value; } }), to = h('input', { type: 'date', value: cfg.to, onchange: (e) => { cfg.to = e.target.value; } }); + const dept = h('select', { onchange: (e) => { cfg.filters.departmentId = e.target.value; } }, h('option', { value: '' }, 'Alle Abteilungen'), ...state.meta.departments.map((d) => h('option', { value: d.id, selected: String(cfg.filters.departmentId) === String(d.id) }, d.name))); + const prio = h('select', { onchange: (e) => { cfg.filters.priorityId = e.target.value; } }, h('option', { value: '' }, 'Alle Prioritäten'), ...state.meta.priorities.map((p) => h('option', { value: p.id, selected: String(cfg.filters.priorityId) === String(p.id) }, p.name))); + const metrics = (meta?.metrics || []).map((m) => h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: cfg.metrics.includes(m.key), onchange: (e) => { cfg.metrics = e.target.checked ? [...cfg.metrics, m.key] : cfg.metrics.filter((k) => k !== m.key); } }), m.label)); + clear(controls).append(h('div', { class: 'row' }, h('label', {}, 'Gruppieren nach'), dim, h('label', {}, 'Von'), from, h('label', {}, 'Bis'), to), h('div', { class: 'row' }, dept, prio), + h('div', { class: 'checks wide' }, ...metrics), + h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run_() }, 'Bericht erstellen'), + h('button', { class: 'btn ghost', onclick: () => { const name = h('input', { placeholder: 'z. B. Monatsbericht Support' }), shared = h('input', { type: 'checkbox' }); + modal('Bericht speichern', h('div', {}, h('label', {}, 'Name'), name, h('label', { class: 'check' }, shared, 'Für alle Mitarbeiter sichtbar')), [{ label: 'Speichern', fn: async (close) => { if (await run(() => post('report_builder.php', { action: 'save', name: name.value, isShared: shared.checked, config: { ...cfg } }), 'Bericht gespeichert')) { close(); run_(); } } }]); } }, 'Bericht speichern'), + h('span', { class: 'muted' }, 'CSV-Export: in der Weboberfläche unter Bericht-Baukasten.'))); + }; + main.append(h('div', { class: 'pagehead' }, h('div', { class: 'grow' }, h('h2', {}, 'Berichte'), h('div', { class: 'sub' }, 'Frei konfigurierbare Auswertungen nach Erstellungsdatum der Tickets'))), controls, savedBar, out); + await run_(); +} + +// ─── Verfügbarkeit ─────────────────────────────────────────────────────────── +async function vAvailability(main) { + const r = await api('availability.php'); + const labels = { available: '🟢 Verfügbar', busy: '🔴 Beschäftigt', break: '🟡 Pause', offline: '⚪ Offline' }; + const sel = h('select', {}, ...Object.entries(labels).map(([v, l]) => h('option', { value: v, selected: v === r.myStatus }, l))); + const note = h('input', { value: r.myNote, placeholder: 'Notiz (optional)' }); + main.append(h('h2', {}, 'Verfügbarkeit'), h('div', { class: 'panel' }, h('label', {}, 'Mein Status'), sel, h('label', {}, 'Notiz'), note, + h('div', { class: 'row' }, h('button', { class: 'btn', onclick: () => run(() => post('availability.php', { status: sel.value, note: note.value }), 'Gespeichert') }, 'Speichern'))), + h('h3', {}, 'Team'), h('div', { class: 'panel' }, h('table', {}, ...r.team.map((m) => h('tr', {}, h('td', {}, m.name), h('td', {}, m.isOnVacation ? '🏖 Urlaub' : labels[m.status] || m.status), h('td', { class: 'muted' }, m.note)))))); +} + +// ─── Verwaltung (rechtegesteuert, wie in der Android-App) ─────────────────── +const T = 'text', N = 'number', B = 'bool', A = 'area', H = 'heading', ES = 'emailsel', XD = 'extradepts'; +const ADMIN = [ + { key: 'departments', label: 'Abteilungen', perm: 'manage_departments', file: 'departments.php', cols: [['Name', 'name'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Mitglieder', (x) => (x.members || []).length], ['Abteilungsleiter', 'managerName'], ['E-Mail', 'email'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], + fields: [['_h1', 'Abteilungsinformationen', H], ['name', 'Name', T], ['isActive', 'Aktiv', B], ['managerId', 'Abteilungsleiter', 'staffOptions?'], ['slaPlanId', 'SLA-Plan (leer = Zeiten der Priorität)', 'slaPlans?'], ['email', 'Abteilungs-E-Mail (optional, sonst Standardadresse)', ES], ['isDefault', 'Standardabteilung', B], ['signature', 'Signatur', A], ['notes', 'Administratornotizen (intern)', A]], + info: (x) => x?.members?.length ? `Mitglieder: ${x.members.map((m) => m.name + (m.isPrimary ? '' : ' (zusätzlich)')).join(', ')}` : '' }, + { key: 'priorities', label: 'Prioritäten / SLA', perm: 'manage_priorities', file: 'priorities.php', cols: [['Name', 'name'], ['Reaktion (h)', 'responseHours'], ['Lösung (h)', 'resolutionHours']], + fields: [['name', 'Name', T], ['color', 'Farbe (#RRGGBB)', T], ['responseHours', 'Reaktionszeit (Std.)', N], ['resolutionHours', 'Lösungszeit (Std.)', N], ['sortOrder', 'Reihenfolge', N]] }, + { key: 'sla_plans', label: 'SLA-Pläne', perm: 'manage_priorities', file: 'sla_plans.php', cols: [['Name', 'name'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Reaktion (Std)', 'responseHours'], ['Lösung (Std)', 'resolutionHours'], ['Abteilungen', 'departmentCount'], ['Hilfethemen', 'helpTopicCount']], + fields: [['name', 'Name', T], ['isActive', 'Aktiv', B], ['responseHours', 'Reaktionszeit (Std)', N], ['resolutionHours', 'Lösungszeit (Std)', N], ['notes', 'Administratornotizen (intern)', A]] }, + { key: 'help_topics', label: 'Hilfethemen', perm: 'manage_help_topics', file: 'help_topics.php', cols: [['Hilfethema', 'path'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Typ', (x) => x.isPublic ? 'Öffentlich' : 'Intern'], ['Priorität', 'priorityName'], ['Abteilung', 'departmentName'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], + fields: [['_h1', 'Hilfethemeninformationen', H], ['name', 'Thema', T], ['isActive', 'Aktiv', B], ['isPublic', 'Öffentlich (für Kunden sichtbar)', B], ['parentId', 'Übergeordnetes Thema', 'items?'], ['notes', 'Interne Notizen (vertraulich)', A], + ['_h2', 'Optionen bei neuem Ticket', H], ['departmentId', 'Abteilung', 'departments'], ['priorityId', 'Priorität', 'priorities'], ['slaPlanId', 'SLA-Plan (leer = Abteilung/Priorität)', 'slaPlans?'], ['sortOrder', 'Reihenfolge', N]] }, + { key: 'kb', label: 'Wissensdatenbank', perm: 'manage_kb', file: 'kb.php', list: 'articles', saveAction: 'save_article', deleteAction: 'delete_article', cols: [['Titel', 'title'], ['Kategorie', 'categoryName'], ['Veröffentlicht', (x) => x.isPublished ? '✓' : ''], ['Aufrufe', 'views']], + fields: [['title', 'Titel', T], ['categoryId', 'Kategorie', 'categories'], ['body', 'Inhalt', A], ['isPublished', 'Veröffentlicht', B]] }, + { key: 'email_addresses', label: 'E-Mail-Adressen', perm: 'manage_email_addresses', file: 'email_addresses.php', cols: [['E-Mail', 'email'], ['Anzeigename', 'label'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Deaktiviert'], ['Abteilung', 'departmentName'], ['Abruf', (x) => x.imapEnabled ? '✓' : '–'], ['Eigener SMTP', (x) => x.smtpEnabled ? '✓' : '–'], ['Aktualisiert', (x) => x.updatedAt ? fmtDate(x.updatedAt) : '']], + actions: [['Test-Mail senden', 'sendTest'], ['Postfach testen', 'testImap'], ['SMTP testen', 'testSmtp']], + fields: [['_h1', 'Konto', H], ['email', 'E-Mail-Adresse', T], ['label', 'Anzeigename', T], ['isActive', 'Aktiv (sonst deaktiviert)', B], ['departmentId', 'Abteilung', 'departments'], ['priorityId', 'Priorität', 'priorities?'], ['helpTopicId', 'Hilfethema', 'helpTopics?'], ['isDefault', 'Standard-Absenderadresse der Abteilung', B], ['notes', 'Interne Notizen', A], + ['_h2', 'Entferntes Postfach (Abruf per IMAP)', H], ['imapEnabled', 'Abruf aktivieren', B], ['imapHost', 'Server', T], ['imapPort', 'Port', N], + ['imapEncryption', 'Verschlüsselung', [['ssl', 'SSL/TLS (993)'], ['tls', 'STARTTLS (143)'], ['none', 'Keine']]], ['imapUsername', 'Benutzername (leer = E-Mail-Adresse)', T], ['imapPassword', 'Passwort (leer = unverändert)', 'pw'], + ['imapFolder', 'Ordner', T], ['imapAfterFetch', 'Aktion nach Abruf', [['seen', 'Als gelesen markieren'], ['archive', 'In Ordner verschieben'], ['delete', 'Löschen']]], ['imapArchiveFolder', 'Zielordner (bei Verschieben)', T], ['imapSpamFolder', 'Spam-/Junk-Ordner', T], + ['_h3', 'Abgehend (eigener SMTP-Server)', H], ['smtpEnabled', 'Eigenen SMTP-Server verwenden', B], ['smtpHost', 'Server', T], ['smtpPort', 'Port', N], + ['smtpEncryption', 'Verschlüsselung', [['tls', 'STARTTLS (587)'], ['ssl', 'SSL/TLS (465)'], ['none', 'Keine']]], ['smtpUsername', 'Benutzername', T], ['smtpPassword', 'Passwort (leer = unverändert)', 'pw']] }, + { key: 'organizations', label: 'Organisationen', perm: 'manage_organizations', file: 'organizations.php', cols: [['Name', 'name'], ['Mitglieder', 'memberCount']], + fields: [['name', 'Name', T], ['notes', 'Notizen', A]] }, + { key: 'staff', label: 'Mitarbeiter', perm: 'manage_staff', file: 'staff_manage.php', cols: [['Name', 'name'], ['E-Mail', 'email'], ['Typ', (x) => x.role === 'admin' ? 'Administrator' : 'Mitarbeiter'], ['Status', (x) => x.isActive ? 'Aktiv' : 'Gesperrt'], ['Hauptabteilung', 'departmentName'], ['Teams', (x) => (x.teamIds || []).length]], + fields: [['_h1', 'Konto', H], ['name', 'Name', T], ['email', 'E-Mail', T], ['isActive', 'Aktiv (sonst gesperrt)', B], ['password', 'Passwort (leer = unverändert)', 'pw'], ['signature', 'Signatur', A], + ['_h2', 'Zugang', H], ['departmentId', 'Hauptabteilung', 'departments?'], ['extraDepartments', 'Erweiterter Zugriff', XD], + ['_h3', 'Berechtigungen', H], ['role', 'Kontotyp', [['agent', 'Mitarbeiter'], ['admin', 'Administrator (alle Rechte)']]], ['roleId', 'Rolle (für Mitarbeiter)', 'roles?'], + ['_h4', 'Teams', H], ['teamIds', 'Mitglied in Teams', 'multi:teams']] }, + { key: 'banlist', label: 'Sperrliste', admin: true, file: 'banlist.php', cols: [['Typ', 'matchType'], ['Wert', 'value'], ['Grund', 'reason'], ['Von', 'addedByName']], saveAction: 'create', noEdit: true, + fields: [['matchType', 'Typ', [['email', 'E-Mail-Adresse'], ['domain', 'Domain']]], ['value', 'Wert', T], ['reason', 'Grund', T]] }, + { key: 'audit', label: 'Admin-Protokoll', perm: 'view_audit_log', file: 'audit_log.php', readonly: true, paged: true, cols: [['Zeit', (x) => fmtDate(x.createdAt)], ['Mitarbeiter', 'staffName'], ['Aktion', 'action']] }, + { key: 'time', label: 'Zeiterfassung', perm: 'view_time_entries', file: 'time_entries.php', readonly: true, list: 'entries', cols: [['Datum', (x) => fmtDate(x.loggedAt)], ['Mitarbeiter', 'staffName'], ['Ticket', 'ticketNumber'], ['Minuten', 'minutes'], ['Notiz', 'note']] }, +]; + +async function vAdmin(main, sectionKey) { + const sections = adminSections(); + if (!sectionKey) { + main.append(h('h2', {}, 'Verwaltung'), h('div', { class: 'tiles' }, ...sections.map((s) => h('div', { class: 'tile', onclick: () => go('admin', s.key) }, s.label))), + h('p', { class: 'muted' }, 'Sichtbar sind nur Bereiche, für die du freigeschaltet bist. Systemnahe Bereiche (Backup, Update, Postfächer, Einstellungen) gibt es nur in der Weboberfläche.')); + return; + } + const s = sections.find((x) => x.key === sectionKey); + if (!s) { main.append(h('div', { class: 'warnbox' }, 'Kein Zugriff.')); return; } + const body = h('div'); let page = 1; + const addBtn = h('button', { class: 'btn', onclick: () => edit(null) }, '+ Neu'); + if (s.readonly || (s.custom && !s.add)) addBtn.classList.add('hidden'); + main.append(h('div', { class: 'row' }, h('button', { class: 'btn ghost', onclick: () => go('admin') }, '‹ Verwaltung'), h('h2', { class: 'grow' }, s.label), addBtn), body); + if (s.custom) return s.custom(body, addBtn); + let resp; + const load = async () => { + resp = await run(() => api(s.file, { query: s.paged ? { page } : {} })); if (!resp) return; + const items = resp[s.list || 'items'] || []; + const cell = (x, c) => typeof c === 'function' ? c(x) : (x[c] ?? ''); + clear(body).append(h('div', { class: 'panel' }, items.length ? h('table', {}, h('tr', {}, ...s.cols.map((c) => h('th', {}, c[0])), s.readonly ? null : h('th', {})), + ...items.map((x) => h('tr', { class: s.readonly || s.noEdit ? '' : 'click', onclick: () => !s.readonly && !s.noEdit && edit(x) }, ...s.cols.map((c) => h('td', {}, cell(x, c[1]))), + s.readonly ? null : h('td', {}, h('button', { class: 'btn danger sm', onclick: (e) => { e.stopPropagation(); remove(x); } }, '✕'))))) : h('div', { class: 'muted' }, 'Keine Einträge.')), + s.paged ? h('div', { class: 'row' }, h('button', { class: 'btn ghost sm', disabled: page <= 1, onclick: () => { page--; load(); } }, '‹'), h('span', { class: 'muted' }, `Seite ${resp.page}`), h('button', { class: 'btn ghost sm', disabled: !resp.hasMore, onclick: () => { page++; load(); } }, '›')) : null); + if (s.key === 'time') body.append(h('p', { class: 'muted' }, `Gesamt: ${resp.totalMinutes} Minuten`)); + }; + const remove = (x) => confirm('Wirklich löschen?') && run(async () => { await post(s.file, { action: s.deleteAction || 'delete', id: x.id }); load(); }, 'Gelöscht'); + function edit(x) { + const inputs = {}; + const rows = s.fields.map(([k, label, type]) => { + let el; + if (type === H) return h('div', { class: 'sect' }, label); + if (type === ES) { + const cur = String(x?.[k] ?? '').toLowerCase(), opts = resp.emailOptions || []; + el = h('select', {}, h('option', { value: '' }, '— Automatisch —'), ...opts.map((o) => h('option', { value: o.email, selected: o.email.toLowerCase() === cur }, `${o.email} (${o.departmentName})`)), + cur && !opts.some((o) => o.email.toLowerCase() === cur) ? h('option', { value: x[k], selected: true }, x[k]) : null); + inputs[k] = el; return [h('label', {}, label), el]; + } + if (type === XD) { + const cur = new Map((x?.[k] || []).map((e) => [e.departmentId, e])); + const boxes = (resp.departments || []).map((d) => ({ + d, on: h('input', { type: 'checkbox', checked: cur.has(d.id) }), nt: h('input', { type: 'checkbox', checked: cur.get(d.id)?.notify ?? true }), + role: h('select', {}, h('option', { value: '' }, 'Hauptrolle'), ...(resp.roles || []).map((r) => h('option', { value: r.id, selected: cur.get(d.id)?.roleId === r.id }, r.name))), + })); + inputs[k] = { get: () => boxes.filter((b) => b.on.checked).map((b) => ({ departmentId: b.d.id, notify: b.nt.checked, roleId: b.role.value === '' ? null : Number(b.role.value) })) }; + return [h('label', {}, label), h('div', {}, ...boxes.map((b) => h('div', { class: 'row' }, h('label', { class: 'check grow' }, b.on, b.d.name), b.role, h('label', { class: 'check' }, b.nt, 'Benachrichtigungen'))))]; + } + if (typeof type === 'string' && type.startsWith('multi:')) { + const cur = new Set(x?.[k] || []), boxes = (resp[type.slice(6)] || []).map((o) => ({ o, el: h('input', { type: 'checkbox', checked: cur.has(o.id) }) })); + inputs[k] = { get: () => boxes.filter((b) => b.el.checked).map((b) => b.o.id) }; + return [h('label', {}, label), h('div', {}, ...(boxes.length ? boxes.map((b) => h('label', { class: 'check' }, b.el, b.o.name)) : [h('span', { class: 'muted' }, 'Keine vorhanden.')]))]; + } + if (type === B) { el = h('input', { type: 'checkbox', checked: x ? !!x[k] : k === 'isActive' || k === 'isPublished' || k === 'isPublic' }); inputs[k] = el; return h('label', { class: 'check' }, el, label); } + if (type === A) el = h('textarea', { value: x?.[k] ?? '' }); + else if (type === N) el = h('input', { type: 'number', value: x?.[k] ?? (k === 'imapPort' ? 993 : k === 'smtpPort' ? 587 : 0) }); + else if (type === 'pw') el = h('input', { type: 'password' }); + else if (typeof type === 'string' && type !== T) { + const key = type.replace('?', ''), opt = type.endsWith('?'); + const list = key === 'items' ? (resp.items || []).filter((o) => !x || o.id !== x.id).map((o) => ({ id: o.id, name: o.path || o.name })) : (resp[key] || []); + el = h('select', {}, opt ? h('option', { value: '' }, '—') : null, ...list.map((o) => h('option', { value: o.id, selected: x && String(x[k]) === String(o.id) }, o.name))); + } else if (Array.isArray(type)) el = h('select', {}, ...type.map(([v, l]) => h('option', { value: v, selected: x && x[k] === v }, l))); + else el = h('input', { value: x?.[k] ?? (k === 'imapFolder' ? 'INBOX' : k === 'imapArchiveFolder' ? 'Processed' : k === 'imapSpamFolder' ? 'Junk' : '') }); + inputs[k] = el; + return [h('label', {}, label), el]; + }); + if (s.info && s.info(x)) rows.push(h('p', { class: 'muted' }, s.info(x))); + const extra = x && s.actions ? s.actions.map(([label, action]) => ({ label, cls: 'ghost', fn: async () => { + const r = await run(() => post(s.file, { action, id: x.id })); if (r) toast(r.message || 'OK'); + } })) : []; + modal(x ? 'Bearbeiten' : 'Neu', h('div', {}, ...rows), [...extra, { label: 'Speichern', fn: async (close) => { + const body = { action: s.saveAction || 'save' }; if (x) body.id = x.id; + for (const [k, , type] of s.fields) { + if (type === H) continue; + if (inputs[k]?.get) { body[k] = inputs[k].get(); continue; } + const el = inputs[k]; let v = type === B ? el.checked : type === N ? Number(el.value) : el.value; + if (typeof type === 'string' && ![T, N, B, A, 'pw', ES].includes(type)) v = v === '' ? null : Number(v); + if (type === 'pw' && v === '') v = ''; + body[k] = v; + } + if (await run(() => post(s.file, body), 'Gespeichert')) { close(); load(); } + } }]); + } + load(); +} + +// ─── Einstellungen ─────────────────────────────────────────────────────────── +async function vSettings(main) { + const p = await window.deskhub.settings(); state.prefs = p; + const save = async (patch, msg) => { state.prefs = await window.deskhub.setSettings(patch); applyTheme(); if ('pollSeconds' in patch) schedulePoll(); if (msg) toast(msg); }; + const check = (key, label, hint) => h('div', { class: 'setting' }, h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!p[key], onchange: (e) => save({ [key]: e.target.checked }, 'Gespeichert') }), label), hint ? h('div', { class: 'muted hint' }, hint) : null); + const session = await window.deskhub.session(); + main.append(h('h2', {}, 'Einstellungen'), + h('div', { class: 'panel' }, h('h3', {}, 'Darstellung'), + h('label', {}, 'Design'), h('select', { onchange: (e) => save({ theme: e.target.value }, 'Gespeichert') }, + ...[['system', 'Wie Windows (automatisch)'], ['light', 'Hell'], ['dark', 'Dunkel']].map(([v, l]) => h('option', { value: v, selected: (p.theme || 'system') === v }, l)))), + h('div', { class: 'panel' }, h('h3', {}, 'Windows'), + check('autostart', 'Mit Windows starten', p.packaged ? 'Startet DeskHub beim Anmelden im Hintergrund (Tray).' : 'Nur in der installierten Version verfügbar.'), + check('closeToTray', 'Beim Schließen im Infobereich weiterlaufen', 'So kommen Benachrichtigungen auch bei geschlossenem Fenster an. Beenden über das Tray-Menü.')), + h('div', { class: 'panel' }, h('h3', {}, 'Benachrichtigungen'), + check('notifications', 'Windows-Benachrichtigungen anzeigen', 'Bei neuen Aktualisierungen an deinen Tickets und neuen, nicht zugewiesenen Tickets.'), + h('label', {}, 'Abfrage-Intervall'), h('select', { onchange: (e) => save({ pollSeconds: +e.target.value }, 'Gespeichert') }, + ...[[30, 'alle 30 Sekunden'], [60, 'jede Minute'], [120, 'alle 2 Minuten'], [300, 'alle 5 Minuten']].map(([v, l]) => h('option', { value: v, selected: Number(p.pollSeconds) === v }, l))), + h('div', { class: 'row top' }, h('button', { class: 'btn ghost sm', onclick: () => window.deskhub.notify('DeskHub', 'Test-Benachrichtigung – so sieht eine Meldung aus.') }, 'Test-Benachrichtigung'))), + h('div', { class: 'panel' }, h('h3', {}, 'Konto'), + h('div', {}, `${state.staff.name} (${state.staff.email})`), h('div', { class: 'muted' }, session?.serverUrl || ''), + h('div', { class: 'row top' }, h('button', { class: 'btn ghost', onclick: doLogout }, 'Abmelden / Server wechseln'), + h('button', { class: 'btn ghost', onclick: () => { for (const k of Object.keys(localStorage).filter((k) => k.startsWith('draft:'))) localStorage.removeItem(k); toast('Entwürfe gelöscht'); } }, 'Gespeicherte Entwürfe löschen'))), + h('div', { class: 'panel muted' }, `DeskHub Desktop ${p.version} · Rechte und Daten kommen vom Server – genau wie in der Android-App.`), + h('div', { class: 'panel' }, h('h3', {}, 'Tastenkürzel'), h('table', {}, ...[['Strg + K', 'Schnellsuche / Befehle'], ['Strg + N', 'Neues Ticket'], ['Strg + Enter', 'Antwort senden'], ['F5', 'Ansicht aktualisieren'], ['Alt + ←', 'Zurück zur Ticketliste'], ['Esc', 'Fenster schließen']] + .map(([k, d]) => h('tr', {}, h('td', {}, h('kbd', {}, k)), h('td', {}, d)))))); +} + +// ─── Schnellsuche (Strg+K) ─────────────────────────────────────────────────── +function openPalette() { + if (!state.staff || document.querySelector('.palette-bg')) return; + const input = h('input', { placeholder: 'Ticketnummer, Betreff, Kunde oder Bereich…', class: 'palette-input' }); + const list = h('div', { class: 'palette-list' }); + const bg = h('div', { class: 'modal-bg palette-bg' }, h('div', { class: 'palette' }, input, list)); + let items = [], idx = 0, timer = null; + const close = () => bg.remove(); + const pages = () => { + const base = [...NAV.map(([k, , l]) => [k, l]), ['availability', 'Verfügbarkeit'], ['settings', 'Einstellungen']]; + if (state.meta?.liveChatEnabled) base.push(['livechat', 'Live-Chat']); + const cmds = base.map(([k, l]) => ({ label: l, hint: 'Bereich', run: () => go(k) })); + if (can('create_ticket')) cmds.push({ label: 'Neues Ticket erstellen', hint: 'Aktion', run: () => go('newticket') }); + for (const s of adminSections()) cmds.push({ label: s.label, hint: 'Verwaltung', run: () => go('admin', s.key) }); + return cmds; + }; + const paint = () => { + clear(list).append(...(items.length ? items.map((it, i) => h('div', { class: 'palette-item' + (i === idx ? ' active' : ''), onclick: () => { close(); it.run(); } }, h('span', {}, it.label), h('span', { class: 'muted' }, it.hint))) : [h('div', { class: 'muted pad' }, 'Keine Treffer.')])); + }; + const refresh = async () => { + const q = input.value.trim().toLowerCase(); + items = pages().filter((c) => !q || c.label.toLowerCase().includes(q)).slice(0, 6); + idx = 0; paint(); + if (q.length >= 2) { + try { + const r = await api('tickets.php', { query: { q: input.value.trim(), perPage: 10, status: 'open,pending,answered,on_hold,resolved,closed' } }); + if (input.value.trim().toLowerCase() !== q) return; + items = [...r.items.map((t) => ({ label: `${t.number} · ${t.subject}`, hint: `${t.requesterName} · ${statusInfo(t.status).label}`, run: () => go('ticket', t.number) })), ...items]; + paint(); + } catch {} + } + }; + input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(refresh, 200); }); + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { idx = Math.min(items.length - 1, idx + 1); paint(); e.preventDefault(); } + else if (e.key === 'ArrowUp') { idx = Math.max(0, idx - 1); paint(); e.preventDefault(); } + else if (e.key === 'Enter' && items[idx]) { close(); items[idx].run(); } + }); + bg.addEventListener('mousedown', (e) => { if (e.target === bg) close(); }); + document.body.append(bg); input.focus(); refresh(); +} + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { const m = document.querySelector('.modal-bg'); if (m) { m.remove(); e.preventDefault(); } return; } + if (!state.staff) return; + const mod = e.ctrlKey || e.metaKey; + if (mod && e.key.toLowerCase() === 'k') { e.preventDefault(); openPalette(); } + else if (mod && e.key.toLowerCase() === 'n' && can('create_ticket')) { e.preventDefault(); go('newticket'); } + else if (mod && e.key.toLowerCase() === 'f' && state.page === 'tickets') { e.preventDefault(); document.getElementById('ticket-search')?.focus(); } + else if (e.key === 'F5') { e.preventDefault(); go(state.page, state.arg); } + else if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); go('tickets'); } +}); + +// ─── Start ─────────────────────────────────────────────────────────────────── +window.deskhub.onSessionExpired(() => { clearTimeout(state.pollTimer); state.staff = null; toast('Sitzung abgelaufen – bitte neu anmelden.', true); showLogin(); }); +window.deskhub.onNavigate((page) => { if (state.staff) go(page); }); +(async () => { + // erst starten, wenn auch admin-extra.js geladen ist (ergänzt ADMIN) + if (document.readyState !== 'complete') await new Promise((r) => window.addEventListener('load', r, { once: true })); + state.prefs = await window.deskhub.settings(); applyTheme(); + const s = await window.deskhub.session(); + if (!s) return showLogin(); + state.serverUrl = s.serverUrl; + const cached = store.get(metaKey(`${s.serverUrl}:${s.staff.id}`), null); + if (cached) { startApp(s.staff, cached, s.serverUrl); return; } + try { await api('me.php'); await startApp(s.staff, null, s.serverUrl); } + catch { showLogin(s.serverUrl); } +})(); diff --git a/desktop/src/assets/icon.ico b/desktop/src/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a0acb97df301666d63b7d59d691c3c2bf0fa1e1b GIT binary patch literal 7162 zcmZWu30M;BGu}b`!nS%eeeO=}m+EQ0Tsb z)peuTow`+rzRBp?_@?KYX;0mT!FyQU-9sz(42Nz_{`_e`N}KZ7wd0-A&HX~>$VaLZ zTFW^512oPL3V6D4YDk?TzM<0gi&d5(iB*$XTP}8`dp4wZ=W?2&%2?rb+OIMchy5<6 zSO^;yb?yquy42u0G?b*cR%AB4{syOELrq{!Z?PFq`DpywPUY~ohi*I@y1O*zP)Ff1 z>a3+xtE+Rfx<5XcaAUsm>GN3Hj}Aj;n>3X(>&j{#K6UXtI9lA;Ur0OXC-T2>b;WG1 zrL%louv1swQ@z3phC9$}?i0$QSs`M_F)7;$%Wo1LOy*rJoSy!X6L90eA8j+yZEe)^ ziMoZoU-aF$LG$7atf=1!Q$kdqq5dQ~mdPus{0)m}Q`Q!4oV@u*+xT{?oa4*2PyCJKy*9PpA##y#qzIlqW13Mfa@hmK4rLo9(3 zl1NS7>iMTj3rwB&PM<(Vv;)iG7BteFSnAefZ#noYXZYtCOX|57E22Av%=AWXobS?V zFDtN4$L3cC*gU@RT6(s58x@ZTX`P`j{_s^6&_pWY7dYZo{uS1JaiUI1XVh=q^ZJU$ z3MCK@RxMID8#7}_-=_KPdR?E{I;=-sjwqb&VU5%1BIf5wgoT;}$mmrqkC0{+2JY z3+zx^M8&6L_UNZZj5T=3*w@>$ilb-RAHlUZ%?$c)AXe=02)|+4#R6kzYU68Hq~M*X zP80ayN2``iH5JTAIb;}#mbRKz@5HMjSAANuE~AMin#z5@(+=LtE?y}1tMN0PDq$ev z_s95)}wy-giciV&fajivi+4IWu>02|; zKbhB}n4r90ipP_;O#V2L==)Vy5psorl)jS`8;q~t&naFg?;Vb4d^89nz}I;v`O(h2 z2PcAuyJ?$}6cpmrOg71DK2k1a1{kHM%(3;@7s&{!b;dH89~lWq_%VOK zvH$q=#|oa2SC>d;KxkyLL#5uCyf9W&?+JM`&)#Nc$lyYEBf$dvdQHwY6EtNUT#qdI zfFyH)+p9%JX2A_*c0i&NN#?1$Fvxax91S$v`eFnRb0c&G7dY3YzW-VM(f~V<6BQrhmK0- zs?!fr$P#mGzBwipWfhdaN_=vM6(dQPp7wIpS#S_P67NHhr3cYZ?dw6F)(oaNIElE2 z1;y+j{5ca{JLU^kGudRXbq_lpWinsY#tc$tdNw~`+_J?=fJ_?hlzQ$&_gQzm5qi_T z_mu-JQ%!=;@xe_*nZ$7mn^<i6P}7FvS0HAp!h zpx}zj2_=@?#mdi3aQSDelvJtgFDLBBL*zrA@YPrNDP^0~>rX)+q3vt=F3 z*cNzj8xX%5U3dLT*YZs+{3?7dN<>HRmk&FxWhr(uNDz#OQGX;F-0502a}NKkcCeZ3v81R2NG|Eku|=p7ImN?f@P*F|0ZBeNl%A- zK)ze!?g%YhnkiiCq1(*nE!h2!r(U|>$QS-DP&4?CC)1h&8aQ{gG?Q(NE6rA*$p{)m<7yq5yAXgX_t5(e%HFBtgxP&7T+W)yTHE z`9a{?VN&`jKABk%gP)=B|04P%n-2YX5n$&L(%e67^+L+@_W8@}(ScapQhkZzUtXzL zg5M=o2L?e>I`E~iA{gIAhVX~L%{4FXFh>)1KzybXaBOrP7EXb%5|F6LH&23qXuU|7 z0=P{|z_=rcqWi%t^-HXk0;~;!1_&U~)*4@Ci+-q);V&YWL54IiHIxKkm%JRl$4QCL ze?N^TOC0$ry_A(<@6mPz+7c?QIL3QJiu$|cy*8CjutHQ6I*h;$8xo25hZ$ zOBbI$>;28N=vcH?AM|2vJ$<%su=jdQneSn_h@Q4CoCAV{ZbxDT>MVeR8qm^2s5yDU zZECHO$s2`>dsQA5?0q~}%%6z8IFjU3NUFBueFEj}PJk||N?5wQE3+AkgHWvFn53qX zj+NM=zaV*pvduEi>s?%^&wo^Gh**u;sHEs_kI>v*izf!JCxMQDrqT3XIa$9x=m`2;grjfVHoH@Ku zFRC>t%u;p)zHJZ0S9s~toFE2%Tsf)hO*CPkr6hfr{MLFW2${?iR+N%{RbArJXXy@c z>1f_rYrF(p`aYt1eE`DHbaDc?7{2hfmnr`=S(1Wxa`PH?e*Rb$&+A)(mKyV>AP7LV zf&@35EA6K66%L!Gy5>uTM!3UqQ;5L%Q|M0UM7Ey4XF#mYl>COR1=7DW&dA1=L6@57 zc1t(OE%6O^*e-ld7-glD0+(Vn)>hJ2hcBtKZeOYx*ae}v#u+l~49Nz~RQSU%Kf9Hb z`g*eI*ck%0@H`B1y|5S> zv}z;0uQ5!m$gBD}b&;3ip;+h9Vv(mGSzj#rS5{I)i;yp|!0tk3m{d*W?I5zw5%)bD zsE!WEnZoq=aL4xvJee`ci;m4#0(pIZ9D(`enrs_Af zi)YY7t|#_vX-;xP%RGcNhcK2T8plsZiVWC>YLEhr&6w-B{?XiO3rH8L$p(2}-#Zxq zGS!hJ{1|ptyr}EVYwDN0R&Q&Mj3yboV)>0Sd$7x{QJrW2QO0IGr2iuU#VuGlzM#>v z4)cVt{M)h|5bO%9-pT-mF^ITJ(_7Ln8CGrU>ZakJ5`oK_&5Q#s%9jc;0E7itV%|Px zJZvj&YzE=Mk%z<)Zf0U`T`W;A6a(3M}x z+UUw`5s%g~X!;#Cn0Yk|cgPsHO8`YIs&(P-Rf;vqx^03=373Y2G0;J<(2Z)H_W16gZwH~`0DSJ!l}?BzA!;MuxxZ)jlakxh>XvETakP3 zful$*p*)j0>+4PL+3_Z}8I_;SY%-%do`(#OXrObXTHihj9v>;Qa%B zv>%1PwH;1)wG%0bC%W~En7=n96&k~wcU5Iw3rE=3JCZSWU;obUAjaMzih36jWA^oJ zt}uK$dyr*BfQjXm>4;PVtlS|0Jvmp{+j^)V?*244Ak7&Rr~AD$0*+c_3w%LoC``vQ zXnKL1>BBr%XctJ7$kOAyZ$Pbc#J9PVFI*H)H40x5rC1oCdj!zL9jiGdtkc#VK1fs$ z9##_?YeYCJi*VLGqUN&w2?CL!63B^ig!ibnNz69^=el5BN~Z7w5DA%*uZ=T)#_Fy4 zC%XITOht`q8j^eqf`O-Vs2d^<6SmL{erg#oeZD=vr^WD9#KsntOl-ZrS#;4osj zSM|Lk|0_*0K9^MxH<-Ih>NS!HDlrk7TB&S`&hOJCUlCejXubzu(PzmlCCbEY*_12) zHKL-V)-n`2jV&TZb~&YOHP&syYVH9HVd40g8;>02ik}~p>od-FUZr6r@vjnXvED{- zLwE@Q2yXABAM~V27Oc5_dMc;$d@=4JrAMsd46l+_5Cz7*`V-OL|<6d19ax$49eVi9KX}8)^!{h&94shqJs!Of4qBk zCUY~s?GvDq70f0_QSs=`T3IVckBqR1b|D&*>2b?r@ebi{+Xwqwzh97}6f$~U{5x~J zobJZoi>TJ>gR{FBcot1^4m-CsZ5C)fQgS32h8`rkvDGZq1#U=|%|JFI#+u~3p(Jgb zWCHKGJI@3lLrgEz%g4~fR1o>G7$2q}MRzochKDosnP;PysPz^Z$mB8LHKHCH_5@+| z)RjlD7+ya|rk=1`1WJ4I$Q(Z@7~-uk!5_M#T!g8n3q&A5L`MLw=Jcp=D%fSFf09Vaq9kKM z_}qshDY{IiIc^WY+rXtA@$*7Z9bJ()Os4)?%hjKWoKoqe!ddVxh|e|wIG%r3c9YqY z1{vcEd)T8726Gx!qj-gyELm)%@Y4|yMQBxeM9AqH4}k3M@D6TeCwYUL34Har_lv}l z@Oo_Q=PoG-9+r`04ulXU#nx-;-1yPc471g9JGVb*y4WHJTqAnPoYN)`KinIPE7;u! zY%ma$Ym}-Jz%ClwBnMD~S8kNm7j{GNR%7^ktR~(;;RK*9FH{<%Yyrt%W`_TyNiGI3 z_PKHA0$m!NFT~~M2ne}ovi>knUqK+mhepFr%;-F_R5aB@pCs{|&^&7h4R=QvX!1}V zHk1G2hAS+lbFdtDv?2#IR`V#j9mbswpm~T3ra}rND{nf~7JJeRGX3*-fMkncIE__r zwGtZ#&5G8W6$&nGy~|*vW=)A(!Ge5ekf92S++Wq<4gM z$$x|i6<0}RU`*3~N|Lh3NPalZK zUKcrfH%#i49tyW5lX=;z6E@FmDgN$s2VYX95junFpT`R0IMa1WJt1!SsXv5o?k5SV zn)k?uPUL~s9zY?0`HA45 zTmZ_20|E@F%k?}%G5DV7WTk9pEQ|DmGf}?)YaqrS6aUZp0P&aj0%&?mF#)4z2NG`C z19H}dps@*-Ouq>N+KJIyFuo0c|2YCzElpp#8v>LOh?Fk{ToY6XLR=LfneRRl=MWST zq3EUwS_zsJz_!K-KKcIfZCS*8DF{ts;A2uBktMSo`$fr2d439oAY!I+r2BHC$}DZR zMwosHH6~MnMhZJuIacPC7!uhW@H*-YLmbdefgm~2dh~Oa6^B71l zLH&VURv&<@y^P_U?vIG^LWfyeB_cfV^7?!73`TdnA%teS9ZWsFHyHXW;!&cKW_sa3 zjN0Fm=%knwV5oE~Zt*1&;++zqH$yFnF^TVy*b2ZhFC32G(a>CQod1#7R;)bZ6X+Y0 zUrn$Gu*D@rbO3)mQM4Pf4mQJQGYCP0Sk2JZ-=VRaM#MNmvJ`dLz4&?eW*7dG?0%Br zC=aas_@xe!g(;sFA~Sf9iwLiN)A4ze{Z^STKH!P;>y`lNQZ^0Css+QF_$^&>3&Pe1 zGbLlw3PDZ6^ku_)F^HGX;@4aDyef!nkC-8QrW&{$!7zbq*vmjuLWMmQNnw+lk8=ln zx*p5RFN%o<#|7KzjsSV~fEu4ydi}oX7Weu*I-{`_qB4kP)j)^A3DKZKO8%C2-*;w2 z;Z|$$jD&wjLBj5&P0GR!I;incR)WO4zITWjSnEDL~#EAni^Z?@?wQr1`wZM zAYf1(G)xlA1MdK4tPxx{*UvQ!XhC{Nc7RSS4qscl+UZJXK-Xyj79U%~^C!qYIB3yj z(1kU#3Us-`=i5JK>3l>%52*ORh`chq$iv$m4vv@m&3rNTdrViLD1L+549$IiWR)@M zHSFoLI4$kmyrA(_Npdo1quv>Kt*L%2ti#u-cGx<`WZ#Cy*0)0eKs+T0rTJ_1uasca z`O6#EsJCPnyp`S}sb0jqlrqy25K&VN`dn*hm?W5sgc8GQk~tw1*j~?X z*G{!3?N+5dmBn8502vIz?ql7R?~aGX6rIyovImUS)L)*}nGjIeimLCuNtnqIG;fpL zYPJvF3KBe%w>9NW^db33&BtuJZv14j5%;R0?NJKl-;$nYNXZ$nok-E zDiLuqYu%NQ+miDAOUFHucgKnobZ-XP9ii2u!2VZl(Xn2I@25L8RZEU`>)X@z4*2xd z%G~=}&2(9hBY}!h#Q3^kpSr}?cX1cI;r0F(_g`Pl`ed-Ss|XB4Z)QcQ*DI6X2U^Eb z&7{rrej~;6Uw=wC+@z{1+NryL8ULbiLO*${?1%a(v<>`a4q6iy8G7Zj?MMC(V1zOE literal 0 HcmV?d00001 diff --git a/desktop/src/assets/logo.png b/desktop/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bf54cbe9f7b120ed8670ecdd78da4b7fb96077e4 GIT binary patch literal 7140 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 + + + + + +DeskHub + + + +
DeskHub
+
+ + + + + diff --git a/desktop/src/main.js b/desktop/src/main.js new file mode 100644 index 00000000..8bfa012e --- /dev/null +++ b/desktop/src/main.js @@ -0,0 +1,185 @@ +const { app, BrowserWindow, ipcMain, safeStorage, Notification, shell, Menu, Tray, nativeImage, nativeTheme } = require('electron'); +const path = require('path'); +const fs = require('fs'); + +app.setAppUserModelId('de.deskhub.desktop'); +let win = null; +const sessionFile = () => path.join(app.getPath('userData'), 'session.bin'); + +// ─── Sitzung (Token DPAPI-verschlüsselt über safeStorage) ─────────────────── +let session = null; // { serverUrl, token, expiresAt, staff } + +function loadSession() { + try { + const raw = fs.readFileSync(sessionFile()); + session = JSON.parse(safeStorage.isEncryptionAvailable() ? safeStorage.decryptString(raw) : raw.toString('utf8')); + } catch { session = null; } +} +function saveSession() { + if (!session) { try { fs.unlinkSync(sessionFile()); } catch {} return; } + const json = JSON.stringify(session); + fs.writeFileSync(sessionFile(), safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(json) : json); +} + +function normalizeUrl(input) { + let s = String(input || '').trim(); + if (!s) throw new Error('Bitte Server-Adresse eingeben.'); + if (!s.includes('://')) s = 'https://' + s; + const u = new URL(s); + if (u.protocol !== 'https:') throw new Error('Die Server-Adresse muss HTTPS verwenden.'); + if (u.username || u.search || u.hash) throw new Error('Die Server-Adresse ist ungültig.'); + return u.origin + u.pathname.replace(/\/+$/, ''); +} + +async function callApi({ path: p, method = 'GET', query, body, auth = true, serverUrl }) { + const base = serverUrl || session?.serverUrl; + if (!base) throw { status: 401, code: 'UNAUTHENTICATED', message: 'Nicht angemeldet.' }; + const url = new URL(`${base}/api/staff/v1/${p}`); + for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); + const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache' }; + if (auth) { + if (!session?.token) throw { status: 401, code: 'UNAUTHENTICATED', message: 'Nicht angemeldet.' }; + headers.Authorization = `Bearer ${session.token}`; + } + const init = { method, headers }; + if (body !== undefined && method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } + let res; + try { res = await fetch(url, { ...init, signal: AbortSignal.timeout(30000) }); } + catch (e) { throw { status: 0, code: 'NETWORK', message: 'Server nicht erreichbar.' }; } + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch {} + if (!res.ok && res.status !== 202) { + if (res.status === 401 && auth) { session = null; saveSession(); win?.webContents.send('session-expired'); } + throw { status: res.status, code: data?.error?.code || `HTTP_${res.status}`, message: data?.error?.message || `Serverfehler (${res.status}).` }; + } + return { status: res.status, data }; +} + +ipcMain.handle('session:get', () => session ? { serverUrl: session.serverUrl, staff: session.staff } : null); + +ipcMain.handle('auth:login', async (_e, { serverUrl, email, password, totpCode }) => { + try { + const base = normalizeUrl(serverUrl); + const r = await callApi({ + path: 'login.php', method: 'POST', auth: false, serverUrl: base, + body: { email, password, totpCode: totpCode || null, deviceName: `Windows Desktop (${require('os').hostname()})` }, + }); + if (r.status === 202 || r.data?.requiresTotp) return { requiresTotp: true }; + session = { serverUrl: base, token: r.data.token, expiresAt: r.data.expiresAt, staff: r.data.staff }; + saveSession(); + return { staff: session.staff }; + } catch (e) { return { error: e.message || String(e) }; } +}); + +ipcMain.handle('auth:logout', async () => { + try { await callApi({ path: 'logout.php', method: 'POST', body: {} }); } catch {} + session = null; saveSession(); + return true; +}); + +ipcMain.handle('api', async (_e, req) => { + if (typeof req?.path !== 'string' || !/^[a-z_]+\.php$/.test(req.path)) return { error: { message: 'Ungültiger Pfad.' } }; + try { + const r = await callApi(req); + if (req.path === 'me.php' && r.data?.staff && session) { session.staff = r.data.staff; saveSession(); } + return { data: r.data }; + } catch (e) { return { error: { status: e.status, code: e.code, message: e.message || String(e) } }; } +}); + +ipcMain.handle('notify', (_e, { title, body }) => { + if (Notification.isSupported()) { + const n = new Notification({ title: String(title).slice(0, 100), body: String(body).slice(0, 200) }); + n.on('click', () => showWindow('tickets')); + n.show(); + } +}); + +// ─── Einstellungen (settings.json im Benutzerordner) ──────────────────────── +const DEFAULTS = { theme: 'system', autostart: false, closeToTray: true, notifications: true, pollSeconds: 60, startMinimized: false, bounds: null }; +let settings = { ...DEFAULTS }; +const settingsFile = () => path.join(app.getPath('userData'), 'settings.json'); +function loadSettings() { try { settings = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) }; } catch { settings = { ...DEFAULTS }; } } +function saveSettings() { try { fs.writeFileSync(settingsFile(), JSON.stringify(settings)); } catch {} } +function applyAutostart() { + if (!app.isPackaged) return; + app.setLoginItemSettings({ openAtLogin: !!settings.autostart, args: settings.autostart ? ['--hidden'] : [] }); +} +ipcMain.handle('settings:get', () => ({ ...settings, version: app.getVersion(), packaged: app.isPackaged })); +ipcMain.handle('settings:set', (_e, patch) => { + for (const k of Object.keys(DEFAULTS)) if (k in patch && k !== 'bounds') settings[k] = patch[k]; + saveSettings(); applyAutostart(); + applyNativeTheme(); + return { ...settings, version: app.getVersion(), packaged: app.isPackaged }; +}); + +// ─── Tray & Zähler ────────────────────────────────────────────────────────── +let tray = null, quitting = false; +function showWindow(page) { + if (!win) return; + win.show(); if (win.isMinimized()) win.restore(); win.focus(); + if (page) win.webContents.send('navigate', page); +} +function createTray() { + const img = nativeImage.createFromPath(path.join(__dirname, 'assets', 'icon.ico')); + tray = new Tray(img.resize({ width: 16, height: 16 })); + tray.setToolTip('DeskHub'); + tray.setContextMenu(Menu.buildFromTemplate([ + { label: 'DeskHub öffnen', click: () => showWindow() }, + { label: 'Tickets', click: () => showWindow('tickets') }, + { label: 'Neues Ticket', click: () => showWindow('newticket') }, + { type: 'separator' }, + { label: 'Beenden', click: () => { quitting = true; app.quit(); } }, + ])); + tray.on('click', () => showWindow()); +} +ipcMain.handle('badge', (_e, { count, dataUrl }) => { + if (!win) return; + try { + win.setOverlayIcon(count > 0 && dataUrl ? nativeImage.createFromDataURL(dataUrl) : null, count > 0 ? `${count} neu` : ''); + tray?.setToolTip(count > 0 ? `DeskHub – ${count} neu` : 'DeskHub'); + } catch {} +}); + +// Hell/Dunkel: "system" folgt Windows; die Titelleiste (Fensterknöpfe) wird passend zum Design eingefärbt. +const resolvedTheme = () => (settings.theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : settings.theme); +const overlayFor = (theme) => (theme === 'light' + ? { color: '#28405a', symbolColor: '#e9eff6', height: 54 } + : { color: '#0a1119', symbolColor: '#e4ebf3', height: 54 }); +function applyNativeTheme() { + nativeTheme.themeSource = ['light', 'dark'].includes(settings.theme) ? settings.theme : 'system'; + try { win?.setTitleBarOverlay(overlayFor(resolvedTheme())); win?.setBackgroundColor(resolvedTheme() === 'light' ? '#eef1f5' : '#0d141d'); } catch {} +} +nativeTheme.on('updated', () => { try { win?.setTitleBarOverlay(overlayFor(resolvedTheme())); } catch {} }); + +function createWindow() { + const b = settings.bounds || {}; + win = new BrowserWindow({ + width: b.width || 1320, height: b.height || 840, x: b.x, y: b.y, minWidth: 960, minHeight: 620, + show: false, titleBarStyle: 'hidden', titleBarOverlay: overlayFor(resolvedTheme()), backgroundColor: resolvedTheme() === 'light' ? '#eef1f5' : '#0d141d', title: 'DeskHub', autoHideMenuBar: true, + icon: path.join(__dirname, 'assets', 'icon.ico'), + webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }, + }); + if (b.maximized) win.maximize(); + win.loadFile(path.join(__dirname, 'index.html')); + win.once('ready-to-show', () => { if (!(process.argv.includes('--hidden') && settings.autostart)) win.show(); }); + const saveBounds = () => { if (!win || win.isMinimized()) return; settings.bounds = { ...win.getNormalBounds(), maximized: win.isMaximized() }; saveSettings(); }; + win.on('resized', saveBounds); win.on('moved', saveBounds); win.on('maximize', saveBounds); win.on('unmaximize', saveBounds); + win.on('close', (e) => { if (!quitting && settings.closeToTray && tray) { e.preventDefault(); win.hide(); } }); + win.on('closed', () => { win = null; }); + // Externe Links nie im App-Fenster öffnen + win.webContents.setWindowOpenHandler(({ url }) => { if (/^https:/i.test(url)) shell.openExternal(url); return { action: 'deny' }; }); + win.webContents.on('will-navigate', (e, url) => { if (!url.startsWith('file://')) e.preventDefault(); }); +} + +// Vom Setup einmalig aufgerufen: Windows prüft die neue EXE dabei (Defender), der echte Start ist danach schnell. +if (process.argv.includes('--prewarm')) app.exit(0); + +const lock = app.requestSingleInstanceLock(); +if (!lock) app.quit(); +else { + app.on('second-instance', () => showWindow()); + app.on('before-quit', () => { quitting = true; }); + app.whenReady().then(() => { Menu.setApplicationMenu(null); loadSettings(); loadSession(); createWindow(); setImmediate(() => { createTray(); applyAutostart(); }); }); + app.on('window-all-closed', () => { if (quitting || !settings.closeToTray) app.quit(); }); +} diff --git a/desktop/src/preload.js b/desktop/src/preload.js new file mode 100644 index 00000000..5014d8b4 --- /dev/null +++ b/desktop/src/preload.js @@ -0,0 +1,14 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('deskhub', { + session: () => ipcRenderer.invoke('session:get'), + login: (data) => ipcRenderer.invoke('auth:login', data), + logout: () => ipcRenderer.invoke('auth:logout'), + api: (req) => ipcRenderer.invoke('api', req), + notify: (title, body) => ipcRenderer.invoke('notify', { title, body }), + settings: () => ipcRenderer.invoke('settings:get'), + setSettings: (patch) => ipcRenderer.invoke('settings:set', patch), + badge: (count, dataUrl) => ipcRenderer.invoke('badge', { count, dataUrl }), + onNavigate: (cb) => ipcRenderer.on('navigate', (_e, page) => cb(page)), + onSessionExpired: (cb) => ipcRenderer.on('session-expired', () => cb()), +}); diff --git a/desktop/src/styles.css b/desktop/src/styles.css new file mode 100644 index 00000000..f054e0fb --- /dev/null +++ b/desktop/src/styles.css @@ -0,0 +1,222 @@ +/* DeskHub Desktop – "Helpdesk Pro": dunkelblaue Navigation, heller Arbeitsbereich, dichte Tabellen. Hell + Dunkel + System. */ +:root { + --bg: #eef1f5; --panel: #ffffff; --panel2: #f4f6f9; --line: #d9dfe6; --line-soft: #e6eaef; + --text: #1c2733; --muted: #667384; --accent: #1f8fe0; --accent2: #1678c2; --accent-soft: rgba(31,143,224,.10); --on-accent: #ffffff; --input: #ffffff; + --ok: #2fa05a; --warn: #d98200; --bad: #d63b3b; --note: #fff6dc; --cust: #eef6fd; --row-sel: #fff8c5; + --chrome: #28405a; --chrome2: #33506f; --chrome-text: #e9eff6; --chrome-muted: #a9b9cb; --chrome-hover: rgba(255,255,255,.08); --chrome-active: rgba(255,255,255,.16); --chrome-line: rgba(255,255,255,.12); + --side: var(--chrome2); + --shadow: 0 14px 36px rgba(28,39,51,.18); --shadow-sm: 0 1px 2px rgba(28,39,51,.08); + --radius: 8px; --radius-sm: 6px; + --font: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif; + color-scheme: light; +} +:root[data-theme="dark"] { + --bg: #0d141d; --panel: #16212d; --panel2: #1b2836; --line: #2a3b4e; --line-soft: #213040; + --text: #e7edf4; --muted: #8c9bad; --accent: #3ea6f0; --accent2: #2f93dd; --accent-soft: rgba(62,166,240,.16); --on-accent: #06192b; --input: #0f1822; + --ok: #4cc47c; --warn: #f0a63a; --bad: #f06b6b; --note: #2a2514; --cust: #152433; --row-sel: #2b2d12; + --chrome: #0a1119; --chrome2: #101b27; --chrome-text: #e4ebf3; --chrome-muted: #7f90a4; --chrome-hover: rgba(255,255,255,.06); --chrome-active: rgba(62,166,240,.18); --chrome-line: rgba(255,255,255,.08); + --shadow: 0 14px 36px rgba(0,0,0,.6); --shadow-sm: 0 1px 2px rgba(0,0,0,.45); + color-scheme: dark; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #0d141d; --panel: #16212d; --panel2: #1b2836; --line: #2a3b4e; --line-soft: #213040; + --text: #e7edf4; --muted: #8c9bad; --accent: #3ea6f0; --accent2: #2f93dd; --accent-soft: rgba(62,166,240,.16); --on-accent: #06192b; --input: #0f1822; + --ok: #4cc47c; --warn: #f0a63a; --bad: #f06b6b; --note: #2a2514; --cust: #152433; --row-sel: #2b2d12; + --chrome: #0a1119; --chrome2: #101b27; --chrome-text: #e4ebf3; --chrome-muted: #7f90a4; --chrome-hover: rgba(255,255,255,.06); --chrome-active: rgba(62,166,240,.18); --chrome-line: rgba(255,255,255,.08); + --shadow: 0 14px 36px rgba(0,0,0,.6); --shadow-sm: 0 1px 2px rgba(0,0,0,.45); + color-scheme: dark; + } +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--text); font: 14px/1.5 var(--font); -webkit-font-smoothing: antialiased; } +#titlebar { position: fixed; top: 0; left: 0; right: 0; height: 54px; z-index: 5; display: flex; align-items: center; gap: 8px; padding: 0 140px 0 14px; background: var(--chrome); color: var(--chrome-muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; -webkit-app-region: drag; user-select: none; } +.logo.sm { width: 18px; height: 18px; border-radius: 5px; font-size: 11px; box-shadow: none; } +#app { height: calc(100% - 54px); margin-top: 54px; } +body.in-app #titlebar { display: none; } body.in-app #app { height: 100%; margin-top: 0; } +.topbar { -webkit-app-region: drag; } .topbar button, .topbar input { -webkit-app-region: no-drag; } +.hidden { display: none !important; } +::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { background: var(--line); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: content-box; } ::-webkit-scrollbar-track { background: transparent; } +button, input, select, textarea { font: inherit; color: inherit; } +input, select, textarea { background: var(--input); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 11px; width: 100%; transition: border-color .15s, box-shadow .15s; } +input:hover, select:hover, textarea:hover { border-color: var(--muted); } +input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +input[type=checkbox] { width: 16px; height: 16px; padding: 0; accent-color: var(--accent); } +input.narrow { width: 80px; } +textarea { resize: vertical; min-height: 110px; } +textarea.tall { min-height: 260px; } textarea.editor { min-height: 140px; } textarea.noteedit { background: var(--note); } +label { display: block; margin: 12px 0 5px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .01em; } +label.check { display: flex; gap: 9px; align-items: center; color: var(--text); font-size: 14px; font-weight: 400; margin: 6px 0; cursor: pointer; } +kbd { background: var(--panel2); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 0 6px; font: 11px var(--font); color: var(--muted); } +h4 { margin: 16px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } + +/* Buttons */ +.btn { background: var(--accent); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer; color: var(--on-accent); font-weight: 600; white-space: nowrap; transition: background .15s, transform .05s, box-shadow .15s; box-shadow: var(--shadow-sm); } +.btn:hover { background: var(--accent2); } +.btn:active { transform: translateY(1px); } +.btn:focus-visible, .nav:focus-visible, .tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.btn.ghost { background: transparent; border-color: var(--line); color: var(--text); box-shadow: none; font-weight: 500; } +.btn.ghost:hover { background: var(--panel2); border-color: var(--muted); } +.btn.danger { background: var(--bad); color: #fff; } .btn.danger:hover { filter: brightness(.92); } +.btn.ok { background: var(--ok); color: #052e16; } +.btn.sm { padding: 3px 10px; font-size: 12px; } +.btn:disabled { opacity: .5; cursor: default; box-shadow: none; } +.muted { color: var(--muted); } .over { color: var(--bad); } .nowrap { white-space: nowrap; } .pad { padding: 12px; } +.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; } +.row.top { margin-top: 12px; } .row.pager { margin-top: 12px; justify-content: center; } +.grow { flex: 1; } .row > select, .row > input:not([type=checkbox]) { width: auto; min-width: 140px; } + +/* Login */ +.login { height: 100%; display: grid; place-items: center; background: radial-gradient(900px 500px at 15% 0%, var(--accent-soft), transparent 60%), radial-gradient(700px 400px at 100% 100%, var(--accent-soft), transparent 55%), var(--bg); } +.login .card { width: 410px; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 34px; box-shadow: var(--shadow); } +.login h1 { margin: 14px 0 4px; font-size: 24px; letter-spacing: -.01em; } +.err { color: var(--bad); margin-top: 10px; min-height: 18px; } + +/* Layout */ +.shell { display: grid; grid-template-columns: 232px 1fr; grid-template-rows: 54px 1fr; height: 100%; } +.topbar { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; padding: 0 154px 0 18px; background: var(--chrome); color: var(--chrome-text); border-bottom: 1px solid var(--chrome-line); } +.topbar .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; letter-spacing: -.01em; min-width: 196px; } +.logo { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(135deg, #3aa7f2, #1769b8); color: #fff; display: grid; place-items: center; font-size: 16px; font-weight: 700; } +.topbar .btn.ok { background: #2fa05a; color: #fff; border-color: transparent; } +.topbar .btn.ok:hover { background: #278a4d; } +.searchbtn { display: flex; justify-content: space-between; align-items: center; gap: 10px; width: 360px; max-width: 38vw; background: #fff; color: #6b7785; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 7px 11px; cursor: pointer; font-weight: 400; } +:root[data-theme="dark"] .searchbtn { background: #0f1822; color: #8c9bad; border-color: var(--chrome-line); } +.searchbtn:hover { box-shadow: 0 0 0 3px var(--accent-soft); } +.searchbtn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.searchbtn .nl { display: flex; align-items: center; gap: 8px; } +.searchbtn kbd { background: #eef1f5; border-color: #d9dfe6; color: #667384; } +:root[data-theme="dark"] .searchbtn kbd { background: #1b2836; border-color: #2a3b4e; color: #8c9bad; } +.topbar .spacer { flex: 1; } +.topbar .who { display: flex; align-items: center; gap: 10px; } +.topbar .avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,.16); display: grid; place-items: center; font-weight: 700; font-size: 12px; } +.topbar .who .name { font-weight: 600; font-size: 13px; line-height: 1.1; } .topbar .who .role { font-size: 11px; color: var(--chrome-muted); } +.topbar .btn.ghost { color: var(--chrome-text); border-color: var(--chrome-line); } .topbar .btn.ghost:hover { background: var(--chrome-hover); border-color: var(--chrome-muted); } +.side { background: var(--chrome2); display: flex; flex-direction: column; padding: 12px 10px; gap: 2px; overflow: auto; } +.nav { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 9px 12px; border-radius: var(--radius-sm); cursor: pointer; color: var(--chrome-muted); border: 0; background: none; text-align: left; width: 100%; font-weight: 500; transition: background .12s, color .12s; position: relative; } +.nav .nl { display: flex; align-items: center; gap: 12px; } +.nav svg { width: 18px; height: 18px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.nav:hover { background: var(--chrome-hover); color: var(--chrome-text); } +.nav.active { background: var(--chrome-active); color: #fff; font-weight: 600; } +.nav.active::before { content: ""; position: absolute; left: -10px; top: 7px; bottom: 7px; width: 3px; border-radius: 0 3px 3px 0; background: #5bbcff; } +.badge { background: var(--bad); color: #fff; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 700; min-width: 20px; text-align: center; } +.spacer { flex: 1; } +.main { overflow: auto; padding: 20px 28px 40px; background: var(--bg); } +h2 { margin: 0 0 18px; font-size: 24px; font-weight: 700; letter-spacing: -.015em; } h3 { margin: 22px 0 10px; font-size: 15px; font-weight: 650; } + +/* Cards / tables */ +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; margin-bottom: 10px; } +.stat { background: var(--panel); border-radius: var(--radius); padding: 16px 18px; border: 1px solid var(--line); box-shadow: var(--shadow-sm); position: relative; overflow: hidden; transition: transform .12s, box-shadow .12s, border-color .12s; } +.stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--accent); } +.stat.link { cursor: pointer; } .stat.link:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--accent); } +.stat .n { font-size: 32px; font-weight: 700; line-height: 1.15; letter-spacing: -.02em; } +.stat.bad::before { background: var(--bad); } .stat.bad .n { color: var(--bad); } +.stat.warn::before { background: var(--warn); } .stat.ok::before { background: var(--ok); } +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; margin-bottom: 16px; } +.panel.flush { padding: 0; overflow: hidden; } +.split2 { display: grid; grid-template-columns: 1fr 330px; gap: 20px; align-items: start; } +.bar { margin-bottom: 12px; } .track { height: 8px; background: var(--panel2); border-radius: 4px; overflow: hidden; margin-top: 5px; } .fill { height: 100%; background: var(--accent); border-radius: 4px; } +table { width: 100%; border-collapse: collapse; } +th { text-align: left; color: var(--text); font-weight: 650; font-size: 12.5px; padding: 9px 12px; background: var(--panel); border-bottom: 2px solid var(--line); white-space: nowrap; } +td { padding: 8px 12px; border-top: 1px solid var(--line-soft); vertical-align: middle; } +table.tickets tr.overdue td:first-child { box-shadow: inset 3px 0 var(--bad); } +.chk { width: 36px; } +tr.click { cursor: pointer; transition: background .08s; } tr.click:hover td { background: var(--accent-soft); } tr.click.sel td { background: var(--row-sel); } +.pill { display: inline-block; padding: 2px 11px; border-radius: 999px; font-size: 11px; font-weight: 600; color: #fff; letter-spacing: .01em; } +.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 7px; } +.toolbar { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } +.toolbar input.search { max-width: 400px; } .toolbar select { max-width: 200px; } +.bulk { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; background: var(--accent); color: var(--on-accent); padding: 9px 14px; border-radius: var(--radius); margin-bottom: 12px; box-shadow: var(--shadow-sm); } +.bulk select { width: auto; color: var(--text); } +.checks { max-height: 230px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 4px 12px; background: var(--input); } +.tabs { display: flex; gap: 4px; margin-bottom: 14px; padding: 4px; background: var(--panel2); border: 1px solid var(--line-soft); border-radius: var(--radius); } .tabs.wrap { flex-wrap: wrap; } +.tab { flex: 1; padding: 7px 14px; border-radius: var(--radius-sm); cursor: pointer; border: 0; background: transparent; color: var(--muted); font-weight: 500; text-align: center; transition: all .12s; } +.tab:hover { background: var(--panel); color: var(--text); } .tab.active { background: var(--accent); color: var(--on-accent); font-weight: 600; box-shadow: var(--shadow-sm); } + +/* Ticket */ +.split { display: grid; grid-template-columns: 1fr 330px; gap: 20px; align-items: start; } +.thread { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 17px; margin-bottom: 12px; border-left: 4px solid var(--line); box-shadow: var(--shadow-sm); } +.thread.staff { border-left-color: var(--accent); } .thread.cust { background: var(--cust); border-left-color: var(--ok); } +.thread.note { border-left-color: var(--warn); background: var(--note); } +.thread .head { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; margin-bottom: 8px; } +.thread .body { white-space: pre-wrap; word-break: break-word; line-height: 1.55; } +.thread .att, .thread .comment { margin-top: 9px; font-size: 12px; } .thread .tools { margin-top: 9px; } +.comment { border-top: 1px dashed var(--line); padding-top: 7px; } +.mini { padding: 6px 6px; cursor: pointer; border-radius: 6px; font-size: 12px; } .mini:hover { background: var(--panel2); } +.warnbox { background: var(--note); border: 1px solid var(--warn); border-radius: var(--radius); padding: 10px 14px; margin-bottom: 14px; } +.setting { margin-bottom: 10px; } .hint { margin: -2px 0 8px 26px; font-size: 12px; } + +/* Chat */ +.chatwrap { display: grid; grid-template-columns: 260px 1fr; gap: 16px; height: calc(100vh - 130px); } +.chatlist { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; } +.chatlist .item { padding: 11px 14px; cursor: pointer; border-bottom: 1px solid var(--line-soft); display: flex; justify-content: space-between; align-items: center; } +.chatlist .item.active, .chatlist .item:hover { background: var(--accent-soft); } +.chatbox { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; min-height: 0; padding-top: 8px; } +.msgs { flex: 1; overflow: auto; padding: 16px; display: flex; flex-direction: column; gap: 9px; } +.msg { max-width: 70%; padding: 8px 13px; border-radius: 14px 14px 14px 4px; background: var(--panel2); white-space: pre-wrap; word-break: break-word; } +.msg.mine { align-self: flex-end; background: var(--accent); color: var(--on-accent); border-radius: 14px 14px 4px 14px; } +.msg small { display: block; opacity: .65; font-size: 10px; } +.chatinput { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--line-soft); } + +/* Modal, Palette, Toast */ +.modal-bg { position: fixed; inset: 0; background: rgba(8,9,20,.55); backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 10; animation: fade .15s ease; } +.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 24px; width: 600px; max-width: 94vw; max-height: 90vh; overflow: auto; box-shadow: var(--shadow); animation: pop .18s ease; } +.modal h2 { font-size: 20px; margin-bottom: 8px; } +.modal .row.rule { flex-wrap: nowrap; } .modal .row.rule > * { min-width: 0; } +.palette-bg { align-items: start; padding-top: 12vh; } +.palette { width: 640px; max-width: 94vw; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); overflow: hidden; animation: pop .15s ease; } +.palette-input { border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 16px 18px; font-size: 16px; background: transparent; } +.palette-input:focus { box-shadow: none; } +.palette-list { max-height: 360px; overflow: auto; padding: 6px; } +.palette-item { display: flex; justify-content: space-between; gap: 12px; padding: 9px 12px; cursor: pointer; border-radius: var(--radius-sm); } +.palette-item.active, .palette-item:hover { background: var(--accent); color: var(--on-accent); } .palette-item.active .muted, .palette-item:hover .muted { color: inherit; opacity: .75; } +.toast { position: fixed; right: 22px; bottom: 22px; background: var(--panel2); border: 1px solid var(--line); border-left: 4px solid var(--ok); padding: 11px 18px; border-radius: var(--radius-sm); z-index: 30; box-shadow: var(--shadow); animation: pop .18s ease; } +.toast.error { border-left-color: var(--bad); } +.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } +.tile { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; cursor: pointer; font-weight: 600; box-shadow: var(--shadow-sm); transition: transform .12s, border-color .12s, box-shadow .12s; } +.tile:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); color: var(--accent); } +.bulk .btn.ghost { color: inherit; border-color: rgba(255,255,255,.55); } .bulk .btn.ghost:hover { background: rgba(255,255,255,.18); } +.sect { margin: 16px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; } +.pagehead { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; } .pagehead h2 { margin: 0 0 2px; } .sub { color: var(--muted); font-size: 13px; } + +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(.985); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } +@media (max-width: 980px) { .split, .split2 { grid-template-columns: 1fr; } .shell { grid-template-columns: 210px 1fr; } .main { padding: 20px; } } + +/* Banner (Verbindung / Systemstörungen) */ +.mainwrap { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } +.mainwrap .main { flex: 1; min-height: 0; } +#banners:empty { display: none; } +.banner { display: flex; align-items: center; gap: 12px; padding: 9px 20px; font-weight: 500; border-bottom: 1px solid var(--line); } +.banner.error { background: var(--bad); color: #fff; border-color: transparent; } +.banner.warn { background: var(--note); color: var(--text); border-bottom-color: var(--warn); } +.banner .btn.ghost { color: inherit; border-color: rgba(255,255,255,.6); } .banner.warn .btn.ghost { border-color: var(--line); } + +/* Gespeicherte Ansichten */ +.viewsbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: -4px 0 12px; } .viewsbar:empty { display: none; } +.viewlabel { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; font-weight: 700; } +.viewchip { display: inline-flex; align-items: center; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); overflow: hidden; } +.viewchip.active { border-color: var(--accent); background: var(--accent-soft); } +.viewbtn, .viewx { border: 0; background: none; cursor: pointer; color: var(--text); padding: 4px 12px; } .viewx { padding: 4px 8px 4px 2px; color: var(--muted); } .viewx:hover { color: var(--bad); } +.viewchip.active .viewbtn { color: var(--accent); font-weight: 600; } + +.checks.wide { display: flex; flex-wrap: wrap; gap: 4px 18px; max-height: none; margin-top: 8px; } .checks.wide .check { margin: 2px 0; } +tr.totalrow td { font-weight: 700; background: var(--panel2); border-top: 2px solid var(--line); } + +/* Ticket-Detailansicht */ +.tkhead { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 20px; margin-bottom: 16px; } +.tkhead-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; } +.tkhead h2 { margin: 0 0 4px; font-size: 22px; } +.chip { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 1px 9px; font-weight: 600; font-size: 12px; color: var(--muted); } +.tkactions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line-soft); } +.sect.first { margin-top: 0; } +.props { display: grid; grid-template-columns: 110px 1fr; gap: 8px 12px; margin: 0; align-items: center; } +.props dt { color: var(--muted); font-size: 12px; font-weight: 600; } .props dd { margin: 0; min-width: 0; } +.props select { width: 100%; } +.avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; font-weight: 700; font-size: 13px; flex: none; } +.avatar.sm { width: 24px; height: 24px; font-size: 10px; } +.custcard { display: flex; align-items: center; gap: 12px; } +.thread .head .who { display: inline-flex; align-items: center; gap: 8px; } +.thread .kind { background: var(--panel2); border-radius: 999px; padding: 0 8px; font-size: 11px; font-weight: 600; } +.thread.note .kind { background: rgba(217,130,0,.18); color: var(--warn); } .thread.cust .kind { background: rgba(47,160,90,.16); color: var(--ok); } +.composer { border-top: 3px solid var(--accent); }