You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

432 regels
16 KiB

  1. // assets/scripts/account.js
  2. import { esc, createTranslator } from './utils.js';
  3. const TOAST_DURATION = 3000;
  4. const t = createTranslator('ACCOUNT');
  5. document.addEventListener('DOMContentLoaded', () => {
  6. const toast = document.getElementById('account-toast');
  7. function showToast(msg, isError = false) {
  8. toast.textContent = msg;
  9. toast.classList.toggle('account-toast--error', isError);
  10. toast.classList.add('account-toast--visible');
  11. setTimeout(() => toast.classList.remove('account-toast--visible'), TOAST_DURATION);
  12. }
  13. async function patchJson(url, data) {
  14. const res = await fetch(url, {
  15. method: 'PATCH',
  16. headers: { 'Content-Type': 'application/json' },
  17. body: JSON.stringify(data),
  18. });
  19. const json = await res.json();
  20. if (!res.ok) throw new Error(json.error ?? t('errorGeneric'));
  21. return json;
  22. }
  23. // ── Farbfeld: Picker <-> Hex-Input synchron + Live-Kontrast ───────────────
  24. const colorPicker = document.getElementById('account-color-picker');
  25. const colorHex = document.getElementById('account-color');
  26. function applyHeaderContrast(hex) {
  27. const r = parseInt(hex.slice(1, 3), 16);
  28. const g = parseInt(hex.slice(3, 5), 16);
  29. const b = parseInt(hex.slice(5, 7), 16);
  30. const brightness = (r * 299 + g * 587 + b * 114) / 1000;
  31. const isLight = brightness > 128;
  32. const root = document.documentElement;
  33. root.style.setProperty('--header-text', isLight ? '#1a2a3a' : '#ffffff');
  34. root.style.setProperty('--header-text-muted', isLight ? 'rgba(26, 42, 58, 0.65)' : 'rgba(255, 255, 255, 0.75)');
  35. root.style.setProperty('--header-overlay', isLight ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.18)');
  36. }
  37. if (colorPicker && colorHex) {
  38. colorPicker.addEventListener('input', () => {
  39. colorHex.value = colorPicker.value;
  40. applyHeaderContrast(colorPicker.value);
  41. });
  42. colorHex.addEventListener('input', () => {
  43. if (/^#[0-9a-fA-F]{6}$/.test(colorHex.value)) {
  44. colorPicker.value = colorHex.value;
  45. applyHeaderContrast(colorHex.value);
  46. }
  47. });
  48. }
  49. // ── Account-Formular ──────────────────────────────────────────────────────
  50. const btnAccountSave = document.getElementById('btn-account-save');
  51. if (btnAccountSave) {
  52. btnAccountSave.addEventListener('click', async () => {
  53. const payload = {
  54. name: document.getElementById('account-name').value.trim(),
  55. trackingInterval: parseInt(document.getElementById('account-interval').value, 10),
  56. };
  57. if (colorHex) {
  58. const hex = colorHex.value.trim();
  59. if (hex && !/^#[0-9a-fA-F]{6}$/.test(hex)) {
  60. showToast(t('invalidHex'), true);
  61. return;
  62. }
  63. payload.primaryColor = hex || '';
  64. }
  65. const lexofficeKey = document.getElementById('account-lexoffice-key');
  66. if (lexofficeKey && !lexofficeKey.hidden) {
  67. const keyVal = lexofficeKey.value.trim();
  68. if (keyVal) payload.lexofficeApiKey = keyVal;
  69. }
  70. btnAccountSave.disabled = true;
  71. try {
  72. await patchJson('/api/account', payload);
  73. showToast(t('savedReloading'));
  74. setTimeout(() => window.location.reload(), 1200);
  75. } catch (e) {
  76. showToast(e.message, true);
  77. } finally {
  78. btnAccountSave.disabled = false;
  79. }
  80. });
  81. }
  82. // ── Besitzer des Accounts ─────────────────────────────────────────────────
  83. const superadminSelect = document.getElementById('superadmin-select');
  84. if (superadminSelect && !superadminSelect.disabled) {
  85. superadminSelect.dataset.original = superadminSelect.value;
  86. superadminSelect.addEventListener('change', async () => {
  87. const selectedName = superadminSelect.options[superadminSelect.selectedIndex].text;
  88. if (!confirm(t('ownerConfirm').replace('%name%', selectedName))) {
  89. superadminSelect.value = superadminSelect.dataset.original;
  90. return;
  91. }
  92. try {
  93. await patchJson('/api/account/superadmin', {
  94. userId: parseInt(superadminSelect.value, 10),
  95. });
  96. showToast(t('ownerChanged'));
  97. setTimeout(() => window.location.reload(), 1500);
  98. } catch (e) {
  99. showToast(e.message, true);
  100. superadminSelect.value = superadminSelect.dataset.original;
  101. }
  102. });
  103. }
  104. // ── Lexoffice-Key-Toggle ───────────────────────────────────────────────────
  105. const btnLexKeyChange = document.getElementById('btn-lexoffice-key-change');
  106. const lexKeyInput = document.getElementById('account-lexoffice-key');
  107. const lexKeyStatus = document.getElementById('lexoffice-key-status');
  108. if (btnLexKeyChange && lexKeyInput && lexKeyStatus) {
  109. btnLexKeyChange.addEventListener('click', (e) => {
  110. e.preventDefault();
  111. const showing = !lexKeyInput.hidden;
  112. lexKeyInput.hidden = showing;
  113. lexKeyStatus.querySelector('.account-form__key-mask').hidden = !showing;
  114. btnLexKeyChange.textContent = showing ? t('changeLabel') : t('cancelLabel');
  115. if (!showing) lexKeyInput.focus();
  116. });
  117. }
  118. // ── Passwort-Toggle ───────────────────────────────────────────────────────
  119. const btnPwToggle = document.getElementById('btn-pw-toggle');
  120. const pwSection = document.getElementById('pw-section');
  121. if (btnPwToggle && pwSection) {
  122. btnPwToggle.addEventListener('click', (e) => {
  123. e.preventDefault();
  124. const open = !pwSection.hidden;
  125. pwSection.hidden = open;
  126. btnPwToggle.textContent = open ? t('changeLabel') : t('cancelLabel');
  127. });
  128. }
  129. // ── Theme-Picker ──────────────────────────────────────────────────────────
  130. const themePicker = document.getElementById('theme-picker');
  131. if (themePicker) {
  132. themePicker.querySelectorAll('input[name="theme"]').forEach(radio => {
  133. radio.addEventListener('change', async () => {
  134. const theme = radio.value;
  135. try {
  136. await patchJson('/api/account/user', { theme });
  137. themePicker.querySelectorAll('.theme-option').forEach(opt => {
  138. opt.classList.toggle('theme-option--active', opt.dataset.theme === theme);
  139. });
  140. document.body.dataset.theme = theme;
  141. showToast(t('themeChanged'));
  142. } catch (e) {
  143. showToast(e.message, true);
  144. }
  145. });
  146. });
  147. }
  148. // ── Benutzer-Formular ─────────────────────────────────────────────────────
  149. const btnUserSave = document.getElementById('btn-user-save');
  150. if (btnUserSave) {
  151. btnUserSave.addEventListener('click', async () => {
  152. const data = {
  153. firstName: document.getElementById('user-firstname').value.trim(),
  154. lastName: document.getElementById('user-lastname').value.trim(),
  155. email: document.getElementById('user-email').value.trim(),
  156. };
  157. if (pwSection && !pwSection.hidden) {
  158. const pwNew = document.getElementById('user-pw-new').value;
  159. const pwRepeat = document.getElementById('user-pw-repeat').value;
  160. if (pwNew !== pwRepeat) {
  161. showToast(t('passwordMismatch'), true);
  162. return;
  163. }
  164. data.currentPassword = document.getElementById('user-pw-current').value;
  165. data.newPassword = pwNew;
  166. }
  167. btnUserSave.disabled = true;
  168. try {
  169. await patchJson('/api/account/user', data);
  170. showToast(t('saved'));
  171. if (pwSection) {
  172. pwSection.hidden = true;
  173. document.getElementById('btn-pw-toggle').textContent = t('changeLabel');
  174. ['user-pw-current', 'user-pw-new', 'user-pw-repeat'].forEach(id => {
  175. document.getElementById(id).value = '';
  176. });
  177. }
  178. } catch (e) {
  179. showToast(e.message, true);
  180. } finally {
  181. btnUserSave.disabled = false;
  182. }
  183. });
  184. }
  185. // ── mite-Import ─────────────────────────────────────────────────────────────
  186. const importFile = document.getElementById('import-file');
  187. const importFileInfo = document.getElementById('import-file-info');
  188. const importFileName = document.getElementById('import-file-name');
  189. const importFileRemove = document.getElementById('import-file-remove');
  190. const btnAnalyze = document.getElementById('btn-import-analyze');
  191. const btnExecute = document.getElementById('btn-import-execute');
  192. const btnReset = document.getElementById('btn-import-reset');
  193. const previewCard = document.getElementById('import-preview');
  194. const previewContent = document.getElementById('import-preview-content');
  195. const resultCard = document.getElementById('import-result');
  196. const resultContent = document.getElementById('import-result-content');
  197. const dropArea = document.getElementById('import-drop-area');
  198. if (importFile) {
  199. let selectedFile = null;
  200. function setFile(file) {
  201. if (!file || !file.name.toLowerCase().endsWith('.xml')) {
  202. showToast(t('importErrorNoFile'), true);
  203. return;
  204. }
  205. selectedFile = file;
  206. importFileName.textContent = file.name + ' (' + formatFileSize(file.size) + ')';
  207. importFileInfo.hidden = false;
  208. btnAnalyze.disabled = false;
  209. previewCard.hidden = true;
  210. resultCard.hidden = true;
  211. }
  212. function clearFile() {
  213. selectedFile = null;
  214. importFile.value = '';
  215. importFileInfo.hidden = true;
  216. btnAnalyze.disabled = true;
  217. previewCard.hidden = true;
  218. resultCard.hidden = true;
  219. }
  220. function formatFileSize(bytes) {
  221. if (bytes < 1024) return bytes + ' B';
  222. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  223. return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  224. }
  225. importFile.addEventListener('change', () => {
  226. if (importFile.files.length > 0) setFile(importFile.files[0]);
  227. });
  228. importFileRemove.addEventListener('click', clearFile);
  229. // Drag & Drop
  230. ['dragenter', 'dragover'].forEach(evt => {
  231. dropArea.addEventListener(evt, (e) => {
  232. e.preventDefault();
  233. dropArea.classList.add('import-upload__area--dragover');
  234. });
  235. });
  236. ['dragleave', 'drop'].forEach(evt => {
  237. dropArea.addEventListener(evt, () => {
  238. dropArea.classList.remove('import-upload__area--dragover');
  239. });
  240. });
  241. dropArea.addEventListener('drop', (e) => {
  242. e.preventDefault();
  243. if (e.dataTransfer.files.length > 0) setFile(e.dataTransfer.files[0]);
  244. });
  245. // Analyse
  246. btnAnalyze.addEventListener('click', async () => {
  247. if (!selectedFile) return;
  248. btnAnalyze.disabled = true;
  249. btnAnalyze.textContent = t('importAnalyzing');
  250. const form = new FormData();
  251. form.append('file', selectedFile);
  252. try {
  253. const res = await fetch('/api/import/mite/preview', { method: 'POST', body: form });
  254. const json = await res.json();
  255. if (!res.ok) throw new Error(json.error ?? t('errorGeneric'));
  256. renderPreview(json);
  257. previewCard.hidden = false;
  258. resultCard.hidden = true;
  259. } catch (e) {
  260. showToast(e.message, true);
  261. } finally {
  262. btnAnalyze.textContent = t('importAnalyze');
  263. btnAnalyze.disabled = false;
  264. }
  265. });
  266. // Import ausführen
  267. btnExecute.addEventListener('click', async () => {
  268. if (!selectedFile) return;
  269. if (!confirm(t('importConfirm'))) return;
  270. btnExecute.disabled = true;
  271. btnExecute.textContent = t('importExecuting');
  272. const form = new FormData();
  273. form.append('file', selectedFile);
  274. const createUserIds = [];
  275. previewContent.querySelectorAll('input[name^="user-mode-"]:checked').forEach(radio => {
  276. if (radio.value === 'create') {
  277. createUserIds.push(parseInt(radio.name.replace('user-mode-', ''), 10));
  278. }
  279. });
  280. form.append('createUsers', JSON.stringify(createUserIds));
  281. try {
  282. const res = await fetch('/api/import/mite/execute', { method: 'POST', body: form });
  283. const json = await res.json();
  284. if (!res.ok) throw new Error(json.error ?? t('errorGeneric'));
  285. renderResult(json);
  286. previewCard.hidden = true;
  287. resultCard.hidden = false;
  288. showToast(t('importSuccess'));
  289. } catch (e) {
  290. showToast(e.message, true);
  291. } finally {
  292. btnExecute.textContent = t('importExecute');
  293. btnExecute.disabled = false;
  294. }
  295. });
  296. // Reset
  297. btnReset.addEventListener('click', () => {
  298. previewCard.hidden = true;
  299. resultCard.hidden = true;
  300. });
  301. function renderPreview(data) {
  302. const rows = [
  303. { label: t('importLabelClients'), value: data.customers },
  304. { label: t('importLabelProjects'), value: data.projects },
  305. { label: t('importLabelServices'), value: data.services },
  306. { label: t('importLabelEntries'), value: data.timeEntries },
  307. ];
  308. let html = '<dl class="import-preview__stats">';
  309. for (const row of rows) {
  310. html += `<dt>${esc(row.label)}</dt><dd>${esc(String(row.value))}</dd>`;
  311. }
  312. if (data.dateRange.from && data.dateRange.to) {
  313. html += `<dt>${esc(t('importLabelDateRange'))}</dt><dd>${esc(formatDate(data.dateRange.from))} – ${esc(formatDate(data.dateRange.to))}</dd>`;
  314. }
  315. html += '</dl>';
  316. // Benutzer-Zuordnung
  317. if (data.users.length > 0) {
  318. html += `<h3 class="import-preview__subtitle">${esc(t('importLabelUsers'))}</h3>`;
  319. html += '<ul class="import-preview__users">';
  320. for (const u of data.users) {
  321. html += `<li><strong>${esc(u.name)}</strong> <span class="import-preview__email">(${esc(u.email)})</span> — ${u.entryCount} ${esc(t('importLabelEntries'))}`;
  322. if (u.matched) {
  323. html += ` <span class="import-badge import-badge--matched">${esc(t('importUserMatched'))} ${esc(u.matchedUserName)}</span>`;
  324. } else {
  325. const radioName = `user-mode-${u.miteId}`;
  326. html += `<div class="import-user-options">`;
  327. html += `<label class="import-user-option"><input type="radio" name="${radioName}" value="me" checked> ${esc(t('importUserAssignMe'))}</label>`;
  328. html += `<label class="import-user-option"><input type="radio" name="${radioName}" value="create"> ${esc(t('importUserCreate'))}</label>`;
  329. html += `</div>`;
  330. }
  331. html += `</li>`;
  332. }
  333. html += '</ul>';
  334. }
  335. // Warnungen
  336. if (data.warnings.length > 0) {
  337. html += `<h3 class="import-preview__subtitle">${esc(t('importLabelWarnings'))}</h3>`;
  338. html += '<ul class="import-preview__warnings">';
  339. for (const w of data.warnings) {
  340. html += `<li>${esc(w)}</li>`;
  341. }
  342. html += '</ul>';
  343. }
  344. previewContent.innerHTML = html;
  345. }
  346. function renderResult(data) {
  347. function statLine(label, total, created) {
  348. const existing = total - created;
  349. let detail = `<strong>${created}</strong> ${esc(t('importNewLabel'))}`;
  350. if (existing > 0) {
  351. detail += `, ${existing} ${esc(t('importExistingLabel'))}`;
  352. }
  353. return `<dt>${esc(label)}</dt><dd>${detail}</dd>`;
  354. }
  355. let html = '<dl class="import-preview__stats import-preview__stats--result">';
  356. html += statLine(t('importLabelClients'), data.clients, data.clientsCreated);
  357. html += statLine(t('importLabelProjects'), data.projects, data.projectsCreated);
  358. html += statLine(t('importLabelServices'), data.services, data.servicesCreated);
  359. html += `<dt>${esc(t('importResultEntries'))}</dt><dd><strong>${data.timeEntries}</strong></dd>`;
  360. if (data.usersCreated > 0) {
  361. html += `<dt>${esc(t('importResultUsers'))}</dt><dd><strong>${data.usersCreated}</strong></dd>`;
  362. }
  363. html += '</dl>';
  364. resultContent.innerHTML = html;
  365. }
  366. function formatDate(dateStr) {
  367. const d = new Date(dateStr + 'T00:00:00');
  368. return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
  369. }
  370. }
  371. });