Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 
 

691 rinda
24 KiB

  1. // assets/scripts/report.js
  2. import { parseAndValidate, initDurationBlurHandler } from './duration.js';
  3. import { esc, createTranslator } from './utils.js';
  4. const t = createTranslator('Report');
  5. // ── Hilfsfunktionen ──────────────────────────────────────────────────────────
  6. function populateProjectSelect(select, selectedId) {
  7. const projects = window.Report?.projects ?? [];
  8. select.innerHTML = '';
  9. projects.forEach(p => {
  10. const opt = document.createElement('option');
  11. opt.value = p.id;
  12. opt.textContent = `${p.clientName} / ${p.name}`;
  13. if (p.id === selectedId) opt.selected = true;
  14. select.appendChild(opt);
  15. });
  16. }
  17. function populateServiceSelect(select, selectedId) {
  18. const services = window.Report?.services ?? [];
  19. const billable = services.filter(s => s.billable);
  20. const notBillable = services.filter(s => !s.billable);
  21. select.innerHTML = `<option value="">${t('selectPh')}</option>`;
  22. function addGroup(label, list) {
  23. if (!list.length) return;
  24. const group = document.createElement('optgroup');
  25. group.label = label;
  26. list.forEach(s => {
  27. const opt = document.createElement('option');
  28. opt.value = s.id;
  29. opt.textContent = s.name;
  30. if (s.id === selectedId) opt.selected = true;
  31. group.appendChild(opt);
  32. });
  33. select.appendChild(group);
  34. }
  35. addGroup(t('billable'), billable);
  36. addGroup(t('notBillable'), notBillable);
  37. }
  38. // ── Edit öffnen ──────────────────────────────────────────────────────────────
  39. function openEdit(row) {
  40. document.querySelectorAll('.report-table__row--editing').forEach(r => {
  41. if (r !== row) closeEdit(r);
  42. });
  43. const editForm = row.querySelector('.report-row__edit');
  44. if (!editForm) return;
  45. const oldProjectSel = row.querySelector('.edit-project');
  46. const oldServiceSel = row.querySelector('.edit-service');
  47. const projectSel = oldProjectSel.cloneNode(false);
  48. const serviceSel = oldServiceSel.cloneNode(false);
  49. oldProjectSel.replaceWith(projectSel);
  50. oldServiceSel.replaceWith(serviceSel);
  51. const projectId = parseInt(row.dataset.projectId, 10) || null;
  52. const serviceId = parseInt(row.dataset.serviceId, 10) || null;
  53. populateProjectSelect(projectSel, projectId);
  54. populateServiceSelect(serviceSel, serviceId);
  55. projectSel.addEventListener('change', () => {
  56. populateServiceSelect(row.querySelector('.edit-service'), null);
  57. });
  58. editForm.hidden = false;
  59. row.classList.add('report-table__row--editing');
  60. row.querySelector('.edit-duration')?.focus();
  61. }
  62. function closeEdit(row) {
  63. const editForm = row.querySelector('.report-row__edit');
  64. if (!editForm) return;
  65. editForm.hidden = true;
  66. row.classList.remove('report-table__row--editing');
  67. }
  68. // ── Speichern ────────────────────────────────────────────────────────────────
  69. async function saveEdit(row) {
  70. const saveBtn = row.querySelector('[data-action="save"]');
  71. if (saveBtn?.disabled) return;
  72. const id = row.dataset.entryId;
  73. const projectId = row.querySelector('.edit-project')?.value;
  74. const label = row.querySelector('.edit-label')?.value;
  75. const serviceId = row.querySelector('.edit-service')?.value;
  76. const note = row.querySelector('.edit-note')?.value ?? '';
  77. if (!projectId) { alert(t('errorNoProject')); return; }
  78. const dur = parseAndValidate(row.querySelector('.edit-duration')?.value ?? '0:00');
  79. if (dur.error) { alert(t(dur.error)); return; }
  80. if (dur.warn && !confirm(t(dur.warn))) return;
  81. if (saveBtn) saveBtn.disabled = true;
  82. try {
  83. const res = await fetch(`/api/entries/${id}`, {
  84. method: 'PATCH',
  85. headers: { 'Content-Type': 'application/json' },
  86. body: JSON.stringify({
  87. duration: dur.formatted,
  88. projectId: parseInt(projectId, 10),
  89. serviceId: serviceId ? parseInt(serviceId, 10) : null,
  90. label: label || null,
  91. note: note || null,
  92. }),
  93. });
  94. if (!res.ok) {
  95. const err = await res.json().catch(() => ({}));
  96. alert(err.error ?? t('errorSave'));
  97. return;
  98. }
  99. window.location.reload();
  100. } catch {
  101. alert(t('errorSave'));
  102. } finally {
  103. if (saveBtn) saveBtn.disabled = false;
  104. }
  105. }
  106. // ── Löschen ──────────────────────────────────────────────────────────────────
  107. async function deleteEntry(row) {
  108. if (!confirm(t('confirmDelete'))) return;
  109. try {
  110. const res = await fetch(`/api/entries/${row.dataset.entryId}`, { method: 'DELETE' });
  111. if (!res.ok) { alert(t('errorDelete')); return; }
  112. window.location.reload();
  113. } catch {
  114. alert(t('errorDelete'));
  115. }
  116. }
  117. // ── Abgerechnet toggeln ──────────────────────────────────────────────────────
  118. async function toggleInvoiced(row) {
  119. const id = row.dataset.entryId;
  120. const btn = row.querySelector('[data-action="toggle-invoiced"]');
  121. try {
  122. const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
  123. if (!res.ok) return;
  124. const data = await res.json();
  125. const invoiced = data.invoiced;
  126. row.dataset.invoiced = invoiced ? 'true' : 'false';
  127. row.classList.toggle('report-table__row--invoiced', invoiced);
  128. if (btn) {
  129. btn.classList.toggle('report-lock--invoiced', invoiced);
  130. btn.title = invoiced ? t('btnUnlock') : t('btnLock');
  131. }
  132. } catch (err) {
  133. console.error('toggleInvoiced error:', err);
  134. }
  135. }
  136. // ── Event-Delegation ─────────────────────────────────────────────────────────
  137. document.addEventListener('DOMContentLoaded', () => {
  138. initDurationBlurHandler();
  139. const table = document.querySelector('.report-table');
  140. if (table) {
  141. table.addEventListener('click', e => {
  142. const btn = e.target.closest('[data-action]');
  143. if (!btn) return;
  144. const row = btn.closest('.report-table__row');
  145. if (!row) return;
  146. switch (btn.dataset.action) {
  147. case 'edit': openEdit(row); break;
  148. case 'cancel': closeEdit(row); break;
  149. case 'save': saveEdit(row); break;
  150. case 'delete': deleteEntry(row); break;
  151. case 'toggle-invoiced': toggleInvoiced(row); break;
  152. }
  153. });
  154. }
  155. initSortHeaders();
  156. new ReportFilter().init();
  157. initExportButtons();
  158. initPrintButton();
  159. initLexofficeInvoiceButton();
  160. });
  161. // ── Filter Label Autocomplete ─────────────────────────────────────────────────
  162. let filterAcDebounce = null;
  163. function initFilterLabelAutocomplete(input, dropdown) {
  164. if (!input || !dropdown) return;
  165. input.addEventListener('input', () => {
  166. clearTimeout(filterAcDebounce);
  167. const q = input.value.trim();
  168. if (q.length < 1) { dropdown.hidden = true; return; }
  169. filterAcDebounce = setTimeout(async () => {
  170. try {
  171. const res = await fetch(`/api/labels?q=${encodeURIComponent(q)}`);
  172. if (!res.ok) return;
  173. const labels = await res.json();
  174. if (!labels.length) { dropdown.hidden = true; return; }
  175. dropdown.innerHTML = labels.map(
  176. l => `<button type="button" class="label-autocomplete__item" data-label="${esc(l)}">${esc(l)}</button>`
  177. ).join('');
  178. dropdown.hidden = false;
  179. } catch { dropdown.hidden = true; }
  180. }, 300);
  181. });
  182. dropdown.addEventListener('click', e => {
  183. const item = e.target.closest('[data-label]');
  184. if (!item) return;
  185. input.value = item.dataset.label;
  186. dropdown.hidden = true;
  187. });
  188. input.addEventListener('blur', () => {
  189. setTimeout(() => { dropdown.hidden = true; }, 200);
  190. });
  191. }
  192. function initAllFilterLabelAutocompletes() {
  193. document.querySelectorAll('.filter-label-input').forEach(input => {
  194. const ac = input.closest('.label-input-wrap')?.querySelector('.filter-label-autocomplete');
  195. initFilterLabelAutocomplete(input, ac);
  196. });
  197. }
  198. // ── ReportFilter ─────────────────────────────────────────────────────────────
  199. class ReportFilter {
  200. constructor() {
  201. this.panel = document.getElementById('report-filter');
  202. this.toggleBtn = document.getElementById('btn-filter-toggle');
  203. this.applyBtn = document.getElementById('btn-filter-apply');
  204. this.hideBtn = document.getElementById('btn-filter-hide');
  205. this.periodSel = document.querySelector('.filter-period-select');
  206. this.customDates = document.querySelector('.filter-custom-dates');
  207. }
  208. init() {
  209. if (!this.panel) return;
  210. this.toggleBtn?.addEventListener('click', () => this.togglePanel());
  211. this.hideBtn?.addEventListener('click', () => this.hidePanel());
  212. this.applyBtn?.addEventListener('click', () => this.applyFilters());
  213. this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
  214. cb.addEventListener('change', () => {
  215. this.syncRowState(cb.closest('.filter-row'), cb.checked);
  216. });
  217. });
  218. this.panel.querySelectorAll('.filter-select, .filter-note-input, .filter-label-input').forEach(el => {
  219. el.addEventListener('mousedown', () => this.activateRowByControl(el));
  220. });
  221. initAllFilterLabelAutocompletes();
  222. this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => {
  223. group.addEventListener('click', () => this.activateRowByControl(group));
  224. });
  225. this.periodSel?.addEventListener('change', () => {
  226. this.activateRowByControl(this.periodSel);
  227. this.toggleCustomDates(this.periodSel.value === 'custom');
  228. });
  229. this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
  230. btn.addEventListener('click', () => this.addControl(btn));
  231. });
  232. this.panel.addEventListener('click', e => {
  233. const removeBtn = e.target.closest('.filter-row__remove');
  234. if (removeBtn) this.removeControl(removeBtn);
  235. });
  236. this.panel.addEventListener('change', e => {
  237. const sel = e.target.closest('.filter-select');
  238. if (!sel) return;
  239. const container = sel.closest('.filter-row__controls');
  240. if (container) this.refreshGroupSelects(container);
  241. });
  242. this.panel.querySelectorAll('.filter-row').forEach(row => {
  243. const cb = row.querySelector('.filter-row__checkbox');
  244. this.syncRowState(row, cb?.checked ?? false);
  245. });
  246. this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
  247. this.refreshGroupSelects(container);
  248. });
  249. }
  250. togglePanel() {
  251. const isHidden = this.panel.hasAttribute('hidden');
  252. if (isHidden) {
  253. this.panel.removeAttribute('hidden');
  254. this.toggleBtn?.classList.add('report-toolbar__action--active');
  255. } else {
  256. this.hidePanel();
  257. }
  258. }
  259. hidePanel() {
  260. this.panel.setAttribute('hidden', '');
  261. this.toggleBtn?.classList.remove('report-toolbar__action--active');
  262. }
  263. syncRowState(row, active) {
  264. row.classList.toggle('filter-row--inactive', !active);
  265. }
  266. activateRowByControl(el) {
  267. const row = el.closest('.filter-row');
  268. if (!row) return;
  269. const cb = row.querySelector('.filter-row__checkbox');
  270. if (cb && !cb.checked) {
  271. cb.checked = true;
  272. this.syncRowState(row, true);
  273. }
  274. }
  275. toggleCustomDates(show) {
  276. if (!this.customDates) return;
  277. this.customDates.toggleAttribute('hidden', !show);
  278. }
  279. addControl(btn) {
  280. const targetId = btn.dataset.target;
  281. const container = document.getElementById(targetId);
  282. if (!container) return;
  283. const template = container.querySelector('.filter-row__control-group');
  284. if (!template) return;
  285. const clone = template.cloneNode(true);
  286. const clonedSelect = clone.querySelector('.filter-select');
  287. if (clonedSelect) clonedSelect.value = '';
  288. const clonedInput = clone.querySelector('.filter-label-input');
  289. if (clonedInput) {
  290. clonedInput.value = '';
  291. const clonedAc = clone.querySelector('.filter-label-autocomplete');
  292. if (clonedAc) { clonedAc.innerHTML = ''; clonedAc.hidden = true; }
  293. initFilterLabelAutocomplete(clonedInput, clonedAc);
  294. clonedInput.addEventListener('mousedown', () => this.activateRowByControl(clonedInput));
  295. }
  296. if (!clone.querySelector('.filter-row__remove')) {
  297. const removeBtn = document.createElement('button');
  298. removeBtn.type = 'button';
  299. removeBtn.className = 'filter-row__remove';
  300. removeBtn.textContent = '×';
  301. clone.appendChild(removeBtn);
  302. }
  303. clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
  304. this.activateRowByControl(clone.querySelector('.filter-select'));
  305. });
  306. container.appendChild(clone);
  307. this.refreshGroupSelects(container);
  308. const row = btn.closest('.filter-row');
  309. const cb = row?.querySelector('.filter-row__checkbox');
  310. if (cb && !cb.checked) {
  311. cb.checked = true;
  312. this.syncRowState(row, true);
  313. }
  314. clonedSelect?.focus();
  315. }
  316. removeControl(removeBtn) {
  317. const group = removeBtn.closest('.filter-row__control-group');
  318. const container = group?.parentElement;
  319. group?.remove();
  320. if (container && !container.querySelector('.filter-row__control-group')) {
  321. const row = container.closest('.filter-row');
  322. const cb = row?.querySelector('.filter-row__checkbox');
  323. if (cb) {
  324. cb.checked = false;
  325. this.syncRowState(row, false);
  326. }
  327. }
  328. if (container) this.refreshGroupSelects(container);
  329. }
  330. refreshGroupSelects(container) {
  331. const selects = [...container.querySelectorAll('.filter-select')];
  332. if (selects.length < 2) return;
  333. const selectedValues = new Set(
  334. selects.map(s => s.value).filter(v => v !== '')
  335. );
  336. selects.forEach(sel => {
  337. const ownValue = sel.value;
  338. sel.querySelectorAll('option').forEach(opt => {
  339. if (!opt.value) return;
  340. opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
  341. });
  342. });
  343. }
  344. applyFilters() {
  345. const params = new URLSearchParams();
  346. params.set('limit', String(window.Report?.limit ?? 50));
  347. this.panel.querySelectorAll('.filter-row').forEach(row => {
  348. const cb = row.querySelector('.filter-row__checkbox');
  349. if (!cb?.checked) return;
  350. const key = row.dataset.filterKey;
  351. if (['clients', 'projects', 'services', 'users'].includes(key)) {
  352. row.querySelectorAll('.filter-select').forEach(sel => {
  353. if (sel.value) params.append(`filter[${key}][]`, sel.value);
  354. });
  355. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  356. params.set(`filter[${key}_neg]`, '1');
  357. }
  358. } else if (key === 'period') {
  359. const val = this.periodSel?.value;
  360. if (!val) return;
  361. params.set('filter[period]', val);
  362. if (val === 'custom' && this.customDates) {
  363. const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
  364. const fromDay = get('from-day').padStart(2, '0');
  365. const fromMonth = get('from-month').padStart(2, '0');
  366. const fromYear = get('from-year');
  367. const toDay = get('to-day').padStart(2, '0');
  368. const toMonth = get('to-month').padStart(2, '0');
  369. const toYear = get('to-year');
  370. if (fromYear && fromMonth && fromDay) {
  371. params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
  372. }
  373. if (toYear && toMonth && toDay) {
  374. params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
  375. }
  376. }
  377. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  378. params.set('filter[period_neg]', '1');
  379. }
  380. } else if (key === 'labels') {
  381. row.querySelectorAll('.filter-label-input').forEach(inp => {
  382. const val = inp.value.trim();
  383. if (val) params.append('filter[labels][]', val);
  384. });
  385. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  386. params.set('filter[labels_neg]', '1');
  387. }
  388. } else if (key === 'note') {
  389. const val = row.querySelector('.filter-note-input')?.value?.trim();
  390. if (val) params.set('filter[note]', val);
  391. } else if (key === 'invoiced') {
  392. const checked = row.querySelector('.filter-invoiced-radio:checked');
  393. if (checked) params.set('filter[invoiced]', checked.value);
  394. }
  395. });
  396. window.location.href = `/reports/times?${params}`;
  397. }
  398. }
  399. // ── Sortierung ───────────────────────────────────────────────────────────────
  400. function initSortHeaders() {
  401. document.querySelectorAll('.report-table__cell--sortable').forEach(cell => {
  402. cell.addEventListener('click', () => {
  403. const params = new URLSearchParams(window.location.search);
  404. params.set('sort', cell.dataset.sort);
  405. params.set('dir', cell.dataset.dir);
  406. window.location.href = `/reports/times?${params}`;
  407. });
  408. });
  409. }
  410. // ── Export ────────────────────────────────────────────────────────────────────
  411. function initExportButtons() {
  412. ['excel', 'csv', 'pdf'].forEach(format => {
  413. document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => {
  414. const params = new URLSearchParams(window.location.search);
  415. params.delete('limit');
  416. window.location.href = `/reports/export/${format}?${params}`;
  417. });
  418. });
  419. }
  420. function initPrintButton() {
  421. document.getElementById('btn-print')?.addEventListener('click', () => {
  422. window.print();
  423. });
  424. }
  425. // ── Lexoffice Invoice Modal ──────────────────────────────────────────────────
  426. function initLexofficeInvoiceButton() {
  427. const btn = document.getElementById('btn-lexoffice-invoice');
  428. if (!btn) return;
  429. const modal = document.getElementById('invoice-modal');
  430. const closeBtn = document.getElementById('invoice-modal-close');
  431. const createBtn = document.getElementById('invoice-modal-create');
  432. const preview = document.getElementById('invoice-preview');
  433. if (!modal || !preview) return;
  434. const contactId = btn.dataset.contactId;
  435. const clientName = btn.dataset.clientName;
  436. const dateFrom = btn.dataset.dateFrom;
  437. const dateTo = btn.dataset.dateTo;
  438. let currentItems = [];
  439. function getFilterParams() {
  440. const params = new URLSearchParams(window.location.search);
  441. params.delete('limit');
  442. params.delete('sort');
  443. params.delete('dir');
  444. return params;
  445. }
  446. function formatNumber(n) {
  447. return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  448. }
  449. function renderPreview(items) {
  450. currentItems = items;
  451. createBtn.disabled = items.length === 0;
  452. if (items.length === 0) {
  453. preview.innerHTML = `<div class="invoice-modal__empty">${esc(t('invoiceNoItems'))}</div>`;
  454. return;
  455. }
  456. let totalRevenue = 0;
  457. const rows = items.map(item => {
  458. const lineTotal = item.hours * item.rate;
  459. totalRevenue += lineTotal;
  460. const descHtml = item.description
  461. ? `<div class="invoice-preview__desc">${esc(item.description)}</div>`
  462. : '';
  463. return `<div class="invoice-preview__row">
  464. <div class="invoice-preview__cell invoice-preview__cell--name">${esc(item.name)}${descHtml}</div>
  465. <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(item.hours)}</div>
  466. <div class="invoice-preview__cell invoice-preview__cell--unit">${esc(t('invoiceUnitHour'))}</div>
  467. <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(item.rate)} €</div>
  468. <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(lineTotal)} €</div>
  469. </div>`;
  470. }).join('');
  471. preview.innerHTML = `
  472. <div class="invoice-preview__table">
  473. <div class="invoice-preview__head">
  474. <div class="invoice-preview__cell invoice-preview__cell--name">${esc(t('invoiceColName'))}</div>
  475. <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColHours'))}</div>
  476. <div class="invoice-preview__cell invoice-preview__cell--unit">${esc(t('invoiceColUnit'))}</div>
  477. <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColRate'))}</div>
  478. <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColTotal'))}</div>
  479. </div>
  480. ${rows}
  481. <div class="invoice-preview__foot">
  482. <div class="invoice-preview__cell invoice-preview__cell--name"></div>
  483. <div class="invoice-preview__cell invoice-preview__cell--num"></div>
  484. <div class="invoice-preview__cell invoice-preview__cell--unit"></div>
  485. <div class="invoice-preview__cell invoice-preview__cell--num"></div>
  486. <div class="invoice-preview__cell invoice-preview__cell--num invoice-preview__cell--total">${formatNumber(totalRevenue)} €</div>
  487. </div>
  488. </div>`;
  489. }
  490. async function loadPreview(groupBy) {
  491. preview.innerHTML = `<div class="invoice-modal__loading">${esc(t('invoiceLoading'))}</div>`;
  492. createBtn.disabled = true;
  493. const params = getFilterParams();
  494. params.set('groupBy', groupBy);
  495. try {
  496. const res = await fetch(`/api/lexoffice/invoice-preview?${params}`);
  497. if (!res.ok) throw new Error();
  498. const items = await res.json();
  499. renderPreview(items);
  500. } catch {
  501. preview.innerHTML = `<div class="invoice-modal__empty">${esc(t('invoiceError'))}</div>`;
  502. }
  503. }
  504. function openModal() {
  505. modal.hidden = false;
  506. const checked = modal.querySelector('input[name="invoice-group"]:checked');
  507. loadPreview(checked?.value ?? 'service');
  508. }
  509. function closeModal() {
  510. modal.hidden = true;
  511. }
  512. btn.addEventListener('click', openModal);
  513. closeBtn.addEventListener('click', closeModal);
  514. modal.addEventListener('click', e => {
  515. if (e.target === modal) closeModal();
  516. });
  517. modal.querySelectorAll('input[name="invoice-group"]').forEach(radio => {
  518. radio.addEventListener('change', () => loadPreview(radio.value));
  519. });
  520. createBtn.addEventListener('click', async () => {
  521. if (createBtn.disabled || currentItems.length === 0) return;
  522. createBtn.disabled = true;
  523. createBtn.textContent = t('invoiceCreating');
  524. const lineItems = currentItems.map(item => {
  525. const li = { name: item.name, quantity: item.hours, unitPrice: item.rate };
  526. if (item.description) li.description = item.description;
  527. return li;
  528. });
  529. try {
  530. const res = await fetch('/api/lexoffice/invoices', {
  531. method: 'POST',
  532. headers: { 'Content-Type': 'application/json' },
  533. body: JSON.stringify({ contactId, dateFrom, dateTo, lineItems }),
  534. });
  535. if (!res.ok) {
  536. const err = await res.json().catch(() => ({}));
  537. alert(err.error ?? t('invoiceError'));
  538. return;
  539. }
  540. const data = await res.json();
  541. const markCb = document.getElementById('invoice-modal-mark-invoiced');
  542. if (markCb?.checked) {
  543. const filterParams = getFilterParams();
  544. await fetch(`/api/entries/mark-invoiced?${filterParams}`, { method: 'POST' }).catch(() => {});
  545. }
  546. closeModal();
  547. const msg = t('invoiceSuccess').replace('%client%', clientName);
  548. const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`);
  549. if (openInLexoffice && data.id) {
  550. window.open(`https://app.lexware.de/permalink/invoices/edit/${data.id}`, '_blank');
  551. }
  552. if (markCb?.checked) {
  553. window.location.reload();
  554. }
  555. } catch {
  556. alert(t('invoiceError'));
  557. } finally {
  558. createBtn.disabled = false;
  559. createBtn.textContent = t('invoiceBtnCreate');
  560. }
  561. });
  562. }