Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 
 

858 righe
32 KiB

  1. // assets/scripts/crud.js
  2. import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js';
  3. import { SearchableSelect } from './searchable-select.js';
  4. const api = window.CRUD?.apiBase ?? '';
  5. const t = createTranslator('CRUD');
  6. // ── Hilfsfunktionen ──────────────────────────────────────────────────────────
  7. function buildClientOptions(selectedId = null) {
  8. const clients = window.CRUD?.clients ?? [];
  9. let html = `<option value="">${t('selectPh')}</option>`;
  10. clients.forEach(c => {
  11. const sel = String(c.id) === String(selectedId) ? ' selected' : '';
  12. html += `<option value="${c.id}"${sel}>${esc(c.name)}</option>`;
  13. });
  14. return html;
  15. }
  16. function rowPrefix() {
  17. if (location.pathname.includes('/clients')) return 'client';
  18. if (location.pathname.includes('/projects')) return 'project';
  19. if (location.pathname.includes('/services')) return 'service';
  20. return 'row';
  21. }
  22. // ── Lexoffice Integration ────────────────────────────────────────────────────
  23. const isClientPage = () => location.pathname.includes('/clients');
  24. const hasLexoffice = () => window.CRUD?.hasLexofficeApiKey === true && isClientPage();
  25. let lexofficeContacts = null;
  26. let lexofficeLoading = false;
  27. async function loadLexofficeContacts() {
  28. if (lexofficeContacts !== null) return lexofficeContacts;
  29. if (lexofficeLoading) {
  30. return new Promise(resolve => {
  31. const check = setInterval(() => {
  32. if (!lexofficeLoading) { clearInterval(check); resolve(lexofficeContacts); }
  33. }, 100);
  34. });
  35. }
  36. lexofficeLoading = true;
  37. try {
  38. const res = await fetch('/api/lexoffice/contacts');
  39. if (!res.ok) throw new Error();
  40. lexofficeContacts = await res.json();
  41. return lexofficeContacts;
  42. } catch {
  43. alert(t('lexofficeErrorLoad'));
  44. return null;
  45. } finally {
  46. lexofficeLoading = false;
  47. }
  48. }
  49. function buildLexofficeSelectInstance(container) {
  50. const ss = new SearchableSelect(container, { searchPlaceholder: t('lexofficeSearch') });
  51. return ss;
  52. }
  53. function getUsedLexofficeIds(excludeRowId) {
  54. const ids = new Set();
  55. document.querySelectorAll('#crud-list .crud-row').forEach(row => {
  56. if (excludeRowId && row.dataset.id === String(excludeRowId)) return;
  57. const id = row.dataset.lexofficeContactId;
  58. if (id) ids.add(id);
  59. });
  60. return ids;
  61. }
  62. async function populateLexofficeSelect(ss, contactId, excludeRowId) {
  63. const contacts = await loadLexofficeContacts();
  64. if (!contacts) return;
  65. const usedIds = getUsedLexofficeIds(excludeRowId);
  66. const filtered = contacts.filter(c => !usedIds.has(c.id) || c.id === contactId);
  67. ss.setGroups([{ label: '', items: filtered }]);
  68. if (contactId) ss.setValue(contactId);
  69. }
  70. function initLexofficeCreateToggle() {
  71. if (!hasLexoffice()) return;
  72. const checkbox = document.getElementById('create-lexoffice');
  73. const nameInput = document.getElementById('create-name');
  74. const selectWrap = document.getElementById('create-lexoffice-select');
  75. const selectLabel = document.getElementById('create-lexoffice-select-label');
  76. const selectField = document.getElementById('create-lexoffice-select-field');
  77. if (!checkbox || !nameInput || !selectWrap) return;
  78. let ss = null;
  79. checkbox.addEventListener('change', async () => {
  80. if (checkbox.checked) {
  81. nameInput.disabled = true;
  82. nameInput.value = '';
  83. if (selectLabel) selectLabel.hidden = false;
  84. if (selectField) selectField.hidden = false;
  85. if (!ss) {
  86. ss = buildLexofficeSelectInstance(selectWrap);
  87. ss.onSelect = (val, label) => { nameInput.value = label; };
  88. selectWrap._ss = ss;
  89. }
  90. await populateLexofficeSelect(ss, null, null);
  91. ss.focus();
  92. } else {
  93. nameInput.disabled = false;
  94. nameInput.value = '';
  95. if (selectLabel) selectLabel.hidden = true;
  96. if (selectField) selectField.hidden = true;
  97. }
  98. });
  99. }
  100. function initLexofficeEditToggles() {
  101. if (!hasLexoffice()) return;
  102. document.querySelectorAll('.crud-row').forEach(row => {
  103. initLexofficeEditToggle(row);
  104. });
  105. }
  106. function initLexofficeEditToggle(row) {
  107. const checkbox = row.querySelector('.edit-lexoffice');
  108. const nameInput = row.querySelector('.edit-name');
  109. const selectWrap = row.querySelector('.edit-lexoffice-select');
  110. const selectLabel = row.querySelector('.edit-lexoffice-select-label');
  111. const selectField = row.querySelector('.edit-lexoffice-select-field');
  112. if (!checkbox || !nameInput) return;
  113. const contactId = (selectWrap?.dataset.contactId) || '';
  114. const rowId = row.dataset.id;
  115. let ss = null;
  116. let fullyLoaded = false;
  117. function ensureSelect() {
  118. if (!ss && selectWrap) {
  119. ss = buildLexofficeSelectInstance(selectWrap);
  120. ss.onSelect = (val, label) => { nameInput.value = label; };
  121. selectWrap._ss = ss;
  122. const origOpen = ss.openDropdown.bind(ss);
  123. ss.openDropdown = async function () {
  124. if (!fullyLoaded) {
  125. await populateLexofficeSelect(ss, contactId, rowId);
  126. fullyLoaded = true;
  127. }
  128. origOpen();
  129. };
  130. }
  131. return ss;
  132. }
  133. if (contactId) {
  134. nameInput.disabled = true;
  135. ensureSelect();
  136. if (ss) {
  137. ss.setGroups([{ label: '', items: [{ id: contactId, name: nameInput.value }] }]);
  138. ss.setValue(contactId);
  139. }
  140. addReloadButton(selectWrap, row);
  141. }
  142. checkbox.addEventListener('change', async () => {
  143. if (checkbox.checked) {
  144. nameInput.disabled = true;
  145. nameInput.value = '';
  146. if (selectLabel) selectLabel.hidden = false;
  147. if (selectField) selectField.hidden = false;
  148. ensureSelect();
  149. if (ss) {
  150. await populateLexofficeSelect(ss, null, rowId);
  151. fullyLoaded = true;
  152. ss.focus();
  153. }
  154. } else {
  155. nameInput.disabled = false;
  156. nameInput.value = '';
  157. if (selectLabel) selectLabel.hidden = true;
  158. if (selectField) selectField.hidden = true;
  159. removeReloadButton(selectWrap);
  160. }
  161. });
  162. }
  163. function addReloadButton(container, row) {
  164. if (!container || container.querySelector('.lexoffice-reload')) return;
  165. const btn = document.createElement('button');
  166. btn.type = 'button';
  167. btn.className = 'lexoffice-reload';
  168. btn.title = t('lexofficeReload');
  169. btn.innerHTML = '<svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
  170. btn.addEventListener('click', async (e) => {
  171. e.preventDefault();
  172. btn.disabled = true;
  173. try {
  174. const res = await fetch(`${api}/${row.dataset.id}/lexoffice-refresh`, { method: 'PATCH' });
  175. if (!res.ok) {
  176. const err = await res.json().catch(() => ({}));
  177. alert(err.error ?? t('lexofficeErrorApi'));
  178. return;
  179. }
  180. const data = await res.json();
  181. updateRowDisplay(row, data);
  182. const nameInput = row.querySelector('.edit-name');
  183. if (nameInput) nameInput.value = data.name;
  184. } catch {
  185. alert(t('lexofficeErrorApi'));
  186. } finally {
  187. btn.disabled = false;
  188. }
  189. });
  190. container.appendChild(btn);
  191. }
  192. function removeReloadButton(container) {
  193. container?.querySelector('.lexoffice-reload')?.remove();
  194. }
  195. function syncLexofficeAfterSave(row, data) {
  196. if (!hasLexoffice()) return;
  197. const selectWrap = row.querySelector('.edit-lexoffice-select');
  198. const nameInput = row.querySelector('.edit-name');
  199. const selectLabel = row.querySelector('.edit-lexoffice-select-label');
  200. const selectField = row.querySelector('.edit-lexoffice-select-field');
  201. const checkbox = row.querySelector('.edit-lexoffice');
  202. const hasId = data.lexofficeContactId != null && data.lexofficeContactId !== '';
  203. if (hasId) {
  204. if (nameInput) nameInput.disabled = true;
  205. if (checkbox) checkbox.checked = true;
  206. if (selectLabel) selectLabel.hidden = false;
  207. if (selectField) selectField.hidden = false;
  208. if (selectWrap) {
  209. const ss = selectWrap._ss ?? (() => {
  210. const s = buildLexofficeSelectInstance(selectWrap);
  211. s.onSelect = (val, label) => { if (nameInput) nameInput.value = label; };
  212. selectWrap._ss = s;
  213. return s;
  214. })();
  215. ss.setGroups([{ label: '', items: [{ id: data.lexofficeContactId, name: data.name }] }]);
  216. ss.setValue(data.lexofficeContactId);
  217. selectWrap.dataset.contactId = data.lexofficeContactId;
  218. let fullyLoaded = false;
  219. const rowId = row.dataset.id;
  220. const origOpen = ss.openDropdown.bind(ss);
  221. ss.openDropdown = async function () {
  222. if (!fullyLoaded) {
  223. await populateLexofficeSelect(ss, data.lexofficeContactId, rowId);
  224. fullyLoaded = true;
  225. }
  226. origOpen();
  227. };
  228. addReloadButton(selectWrap, row);
  229. }
  230. } else {
  231. if (nameInput) nameInput.disabled = false;
  232. if (checkbox) checkbox.checked = false;
  233. if (selectLabel) selectLabel.hidden = true;
  234. if (selectField) selectField.hidden = true;
  235. if (selectWrap) {
  236. removeReloadButton(selectWrap);
  237. selectWrap.dataset.contactId = '';
  238. }
  239. }
  240. }
  241. // ── Rate-Mode Radio Toggle ───────────────────────────────────────────────────
  242. function initRateModeToggles() {
  243. document.addEventListener('change', e => {
  244. if (e.target.type !== 'radio') return;
  245. const container = e.target.closest('.rate-mode');
  246. if (!container) return;
  247. const inputWrap = container.querySelector('.rate-mode__input');
  248. if (!inputWrap) return;
  249. inputWrap.hidden = e.target.value !== 'custom';
  250. if (e.target.value === 'custom') {
  251. inputWrap.querySelector('input')?.focus();
  252. }
  253. });
  254. }
  255. // ── Create-Formular ──────────────────────────────────────────────────────────
  256. function initCreateForm() {
  257. const btnNew = document.getElementById('btn-new');
  258. const form = document.getElementById('crud-create');
  259. const btnSave = document.getElementById('btn-create-save');
  260. const btnCancel = document.getElementById('btn-create-cancel');
  261. if (!btnNew || !form) return;
  262. btnNew.addEventListener('click', () => {
  263. form.classList.toggle('crud-create--visible');
  264. document.getElementById('create-name')?.focus();
  265. });
  266. btnCancel?.addEventListener('click', () => {
  267. form.classList.remove('crud-create--visible');
  268. resetCreateForm();
  269. });
  270. btnSave?.addEventListener('click', () => createEntity());
  271. }
  272. function resetCreateForm() {
  273. ['create-name', 'create-note'].forEach(id => {
  274. const el = document.getElementById(id);
  275. if (el) { el.value = ''; el.disabled = false; }
  276. });
  277. const rate = document.getElementById('create-rate');
  278. if (rate) rate.value = '';
  279. const billable = document.getElementById('create-billable');
  280. if (billable) billable.checked = true;
  281. const client = document.getElementById('create-client');
  282. if (client) client.value = '';
  283. const defaultRadio = document.querySelector('[name="create-rate-mode"][value="default"]');
  284. if (defaultRadio) {
  285. defaultRadio.checked = true;
  286. const rateInput = defaultRadio.closest('.rate-mode')?.querySelector('.rate-mode__input');
  287. if (rateInput) rateInput.hidden = true;
  288. }
  289. const lexofficeCheckbox = document.getElementById('create-lexoffice');
  290. if (lexofficeCheckbox) {
  291. lexofficeCheckbox.checked = false;
  292. const selectWrap = document.getElementById('create-lexoffice-select');
  293. if (selectWrap) selectWrap.hidden = true;
  294. }
  295. }
  296. async function createEntity() {
  297. const name = document.getElementById('create-name')?.value?.trim();
  298. if (!name) { alert(t('errorNoName')); return; }
  299. const btn = document.getElementById('btn-create-save');
  300. const body = buildCreateBody();
  301. if (btn) btn.disabled = true;
  302. try {
  303. const res = await fetch(api, {
  304. method: 'POST',
  305. headers: { 'Content-Type': 'application/json' },
  306. body: JSON.stringify(body),
  307. });
  308. if (!res.ok) {
  309. const err = await res.json().catch(() => ({}));
  310. alert(err.error ?? t('errorSave'));
  311. return;
  312. }
  313. const data = await res.json();
  314. appendRowToList(data);
  315. document.getElementById('crud-create')?.classList.remove('crud-create--visible');
  316. resetCreateForm();
  317. } catch {
  318. alert(t('errorSave'));
  319. } finally {
  320. if (btn) btn.disabled = false;
  321. }
  322. }
  323. function buildCreateBody() {
  324. const body = {
  325. name: document.getElementById('create-name')?.value?.trim(),
  326. note: document.getElementById('create-note')?.value || null,
  327. };
  328. const rateMode = document.querySelector('[name="create-rate-mode"]:checked');
  329. const rate = document.getElementById('create-rate');
  330. if (rateMode) {
  331. body.hourlyRate = rateMode.value === 'custom' && rate?.value ? rate.value : null;
  332. } else if (rate) {
  333. body.hourlyRate = rate.value || null;
  334. }
  335. const client = document.getElementById('create-client');
  336. if (client) body.clientId = parseInt(client.value, 10) || null;
  337. const billable = document.getElementById('create-billable');
  338. if (billable) body.billable = billable.checked;
  339. const lexofficeCheckbox = document.getElementById('create-lexoffice');
  340. const lexofficeSelect = document.getElementById('create-lexoffice-select');
  341. if (lexofficeCheckbox?.checked && lexofficeSelect?._ss) {
  342. body.lexofficeContactId = lexofficeSelect._ss.getValue() || null;
  343. } else if (lexofficeCheckbox && !lexofficeCheckbox.checked) {
  344. body.lexofficeContactId = null;
  345. }
  346. return body;
  347. }
  348. // ── Liste: Event Delegation ──────────────────────────────────────────────────
  349. function initList() {
  350. const list = document.getElementById('crud-list');
  351. if (!list) return;
  352. list.addEventListener('click', e => {
  353. const actionEl = e.target.closest('[data-action]');
  354. if (!actionEl) return;
  355. const row = e.target.closest('.crud-row');
  356. if (!row) return;
  357. switch (actionEl.dataset.action) {
  358. case 'edit': openEdit(row); break;
  359. case 'delete': deleteRow(row); break;
  360. case 'save': saveEdit(row); break;
  361. case 'cancel': closeEdit(row); break;
  362. case 'unarchive': unarchiveRow(row); break;
  363. }
  364. });
  365. }
  366. // ── Inline Edit ──────────────────────────────────────────────────────────────
  367. function openEdit(row) {
  368. row.querySelector('.crud-row__display').hidden = true;
  369. row.querySelector('.crud-row__edit').hidden = false;
  370. row.querySelector('.edit-name')?.focus();
  371. }
  372. function closeEdit(row) {
  373. row.querySelector('.crud-row__display').hidden = false;
  374. row.querySelector('.crud-row__edit').hidden = true;
  375. }
  376. async function saveEdit(row) {
  377. const saveBtn = row.querySelector('[data-action="save"]');
  378. if (saveBtn?.disabled) return;
  379. const name = row.querySelector('.edit-name')?.value?.trim();
  380. if (!name) { alert(t('errorNoName')); return; }
  381. const body = buildEditBody(row);
  382. if (saveBtn) saveBtn.disabled = true;
  383. try {
  384. const res = await fetch(`${api}/${row.dataset.id}`, {
  385. method: 'PATCH',
  386. headers: { 'Content-Type': 'application/json' },
  387. body: JSON.stringify(body),
  388. });
  389. if (!res.ok) { alert(t('errorSave')); return; }
  390. const data = await res.json();
  391. updateRowDisplay(row, data);
  392. syncLexofficeAfterSave(row, data);
  393. closeEdit(row);
  394. const list = document.getElementById('crud-list');
  395. if (list && data.billable === undefined) insertRowSorted(list, row);
  396. } catch {
  397. alert(t('errorSave'));
  398. } finally {
  399. if (saveBtn) saveBtn.disabled = false;
  400. }
  401. }
  402. function buildEditBody(row) {
  403. const body = {
  404. name: row.querySelector('.edit-name')?.value?.trim(),
  405. note: row.querySelector('.edit-note')?.value || null,
  406. };
  407. const rateMode = row.querySelector('.rate-mode input[type="radio"][value="custom"]');
  408. const rate = row.querySelector('.edit-rate');
  409. if (rateMode) {
  410. body.hourlyRate = rateMode.checked && rate?.value ? rate.value : null;
  411. } else if (rate) {
  412. body.hourlyRate = rate.value || null;
  413. }
  414. const client = row.querySelector('.edit-client');
  415. if (client) body.clientId = parseInt(client.value, 10) || null;
  416. const billable = row.querySelector('.edit-billable');
  417. if (billable) body.billable = billable.checked;
  418. const lexofficeCheckbox = row.querySelector('.edit-lexoffice');
  419. const lexofficeSelect = row.querySelector('.edit-lexoffice-select');
  420. if (lexofficeCheckbox?.checked && lexofficeSelect?._ss) {
  421. body.lexofficeContactId = lexofficeSelect._ss.getValue() || null;
  422. } else if (lexofficeCheckbox && !lexofficeCheckbox.checked) {
  423. body.lexofficeContactId = null;
  424. }
  425. return body;
  426. }
  427. function updateRowDisplay(row, data) {
  428. const nameEl = row.querySelector('.crud-row__name');
  429. const metaEl = row.querySelector('.crud-row__meta');
  430. if (nameEl) nameEl.textContent = data.name;
  431. if (data.clientName && metaEl) metaEl.textContent = data.clientName;
  432. row.dataset.name = data.name;
  433. if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
  434. if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
  435. if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
  436. if (data.note !== undefined) row.dataset.note = data.note ?? '';
  437. if (data.lexofficeContactId !== undefined) row.dataset.lexofficeContactId = data.lexofficeContactId ?? '';
  438. const editName = row.querySelector('.edit-name');
  439. if (editName) editName.value = data.name;
  440. const editNote = row.querySelector('.edit-note');
  441. if (editNote) editNote.value = data.note ?? '';
  442. const editRate = row.querySelector('.edit-rate');
  443. if (editRate) editRate.value = data.hourlyRate ?? '';
  444. const rateMode = row.querySelector('.rate-mode');
  445. if (rateMode) {
  446. const hasCustom = data.hourlyRate != null && data.hourlyRate !== '';
  447. const defaultRadio = rateMode.querySelector('input[value="default"]');
  448. const customRadio = rateMode.querySelector('input[value="custom"]');
  449. const inputWrap = rateMode.querySelector('.rate-mode__input');
  450. if (defaultRadio) defaultRadio.checked = !hasCustom;
  451. if (customRadio) customRadio.checked = hasCustom;
  452. if (inputWrap) inputWrap.hidden = !hasCustom;
  453. }
  454. const editBillable = row.querySelector('.edit-billable');
  455. if (editBillable) editBillable.checked = !!data.billable;
  456. }
  457. // ── Delete ───────────────────────────────────────────────────────────────────
  458. async function deleteRow(row) {
  459. if (!confirm(t('confirmDelete'))) return;
  460. try {
  461. const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });
  462. if (res.status === 409) {
  463. if (confirm(t('confirmArchive'))) {
  464. await archiveRow(row);
  465. }
  466. return;
  467. }
  468. if (!res.ok) { alert(t('errorDelete')); return; }
  469. removeWithAnimation(row, 'crud-row--removing');
  470. } catch {
  471. alert(t('errorDelete'));
  472. }
  473. }
  474. async function archiveRow(row) {
  475. try {
  476. const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
  477. if (!res.ok) { alert(t('errorArchive')); return; }
  478. row.dataset.archived = '1';
  479. row.classList.add('crud-row--archived');
  480. updateRowArchivedState(row, true);
  481. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  482. } catch {
  483. alert(t('errorArchive'));
  484. }
  485. }
  486. async function unarchiveRow(row) {
  487. try {
  488. const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
  489. if (!res.ok) { alert(t('errorRestore')); return; }
  490. row.dataset.archived = '0';
  491. row.classList.remove('crud-row--archived');
  492. updateRowArchivedState(row, false);
  493. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  494. } catch {
  495. alert(t('errorRestore'));
  496. }
  497. }
  498. function updateRowArchivedState(row, archived) {
  499. const actions = row.querySelector('.crud-row__actions');
  500. if (!actions) return;
  501. if (archived) {
  502. actions.innerHTML = `
  503. <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="${t('btnRestore')}">
  504. <svg viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 1 1.5 3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 13V9h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
  505. </button>`;
  506. row.querySelector('.crud-row__edit')?.remove();
  507. } else {
  508. actions.innerHTML = `
  509. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
  510. <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
  511. </button>
  512. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
  513. <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
  514. </button>`;
  515. }
  516. }
  517. function filterByTab(tab) {
  518. document.querySelectorAll('#crud-list .crud-row').forEach(row => {
  519. row.hidden = tab === 'active'
  520. ? row.dataset.archived === '1'
  521. : row.dataset.archived === '0';
  522. });
  523. }
  524. function initTabs() {
  525. const tabs = document.querySelectorAll('.crud-tab');
  526. if (!tabs.length) return;
  527. filterByTab('active');
  528. tabs.forEach(tab => {
  529. tab.addEventListener('click', () => {
  530. tabs.forEach(t => t.classList.remove('crud-tab--active'));
  531. tab.classList.add('crud-tab--active');
  532. filterByTab(tab.dataset.tab);
  533. const btnNew = document.getElementById('btn-new');
  534. if (btnNew) btnNew.hidden = tab.dataset.tab === 'archived';
  535. });
  536. });
  537. }
  538. // ── Sortierte Einfügung ─────────────────────────────────────────────────────
  539. function insertRowSorted(container, row) {
  540. const name = (row.dataset.name || '').toLowerCase();
  541. const rows = container.querySelectorAll('.crud-row:not(.crud-row--archived)');
  542. for (const existing of rows) {
  543. if (existing === row) continue;
  544. if ((existing.dataset.name || '').toLowerCase() > name) {
  545. existing.before(row);
  546. return;
  547. }
  548. }
  549. container.appendChild(row);
  550. }
  551. // ── Neue Zeile einfügen ──────────────────────────────────────────────────────
  552. function appendRowToList(data) {
  553. const list = document.getElementById('crud-list');
  554. if (!list) return;
  555. const html = buildRowHTML(data);
  556. if (data.billable !== undefined) {
  557. const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable');
  558. let targetGroup = null;
  559. list.querySelectorAll('.crud-list__group').forEach(g => {
  560. if (g.querySelector('.crud-list__group-label')?.textContent === groupLabel) {
  561. targetGroup = g;
  562. }
  563. });
  564. if (targetGroup) {
  565. targetGroup.insertAdjacentHTML('beforeend', html);
  566. } else {
  567. const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${esc(groupLabel)}</div>${html}</div>`;
  568. if (!data.billable) {
  569. list.insertAdjacentHTML('beforeend', groupHtml);
  570. } else {
  571. const firstGroup = list.querySelector('.crud-list__group');
  572. firstGroup
  573. ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
  574. : list.insertAdjacentHTML('beforeend', groupHtml);
  575. }
  576. }
  577. } else {
  578. list.insertAdjacentHTML('beforeend', html);
  579. }
  580. const prefix = rowPrefix();
  581. const el = document.getElementById(`${prefix}-${data.id}`);
  582. if (el) {
  583. if (data.billable === undefined) insertRowSorted(list, el);
  584. animateIn(el, 'crud-row--new');
  585. if (hasLexoffice()) initLexofficeEditToggle(el);
  586. syncLexofficeAfterSave(el, data);
  587. }
  588. }
  589. function buildRowHTML(data) {
  590. const prefix = rowPrefix();
  591. let metaHtml = '';
  592. let editFields = '';
  593. if (data.projectCount !== undefined) {
  594. const c = data.projectCount;
  595. const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== '';
  596. const isLex = data.lexofficeContactId != null && data.lexofficeContactId !== '';
  597. metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`;
  598. const lexCheckbox = hasLexoffice() ? `
  599. <label class="entry-form__label">&nbsp;</label>
  600. <div class="entry-form__field">
  601. <label class="crud-checkbox-label">
  602. <input type="checkbox" class="edit-lexoffice" ${isLex ? 'checked' : ''} />
  603. <span>${t('lexofficeCheckbox')}</span>
  604. </label>
  605. </div>` : '';
  606. const lexSelect = hasLexoffice() ? `
  607. <label class="entry-form__label edit-lexoffice-select-label" hidden>${t('lexofficeSelect')}</label>
  608. <div class="entry-form__field edit-lexoffice-select-field" hidden>
  609. <div class="lexoffice-select-wrap edit-lexoffice-select"
  610. data-placeholder="${t('lexofficeSelect')}"
  611. data-contact-id="${esc(data.lexofficeContactId ?? '')}"></div>
  612. </div>` : '';
  613. editFields = `${lexCheckbox}${lexSelect}
  614. <label class="entry-form__label">${t('labelName')}</label>
  615. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" ${isLex ? 'disabled' : ''} /></div>
  616. <label class="entry-form__label">${t('labelRate')}</label>
  617. <div class="entry-form__field">
  618. <div class="rate-mode">
  619. <label class="rate-mode__option">
  620. <input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} />
  621. <span>${t('rateModeDefault')}</span>
  622. </label>
  623. <label class="rate-mode__option">
  624. <input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} />
  625. <span>${t('rateModeCustom')}</span>
  626. </label>
  627. <div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}>
  628. <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
  629. <span class="entry-form__unit">€</span>
  630. </div>
  631. </div>
  632. </div>
  633. <label class="entry-form__label">${t('labelNote')}</label>
  634. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  635. }
  636. if (data.clientName !== undefined && data.projectCount === undefined) {
  637. const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== '';
  638. metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`;
  639. editFields = `
  640. <label class="entry-form__label">${t('labelName')}</label>
  641. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  642. <label class="entry-form__label">${t('labelClient')}</label>
  643. <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
  644. <label class="entry-form__label">${t('labelRate')}</label>
  645. <div class="entry-form__field">
  646. <div class="rate-mode">
  647. <label class="rate-mode__option">
  648. <input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} />
  649. <span>${t('rateModeDefault')}</span>
  650. </label>
  651. <label class="rate-mode__option">
  652. <input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} />
  653. <span>${t('rateModeCustom')}</span>
  654. </label>
  655. <div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}>
  656. <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
  657. <span class="entry-form__unit">€</span>
  658. </div>
  659. </div>
  660. </div>
  661. <label class="entry-form__label">${t('labelNote')}</label>
  662. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  663. }
  664. if (data.billable !== undefined) {
  665. editFields = `
  666. <label class="entry-form__label">${t('labelName')}</label>
  667. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  668. <label class="entry-form__label">${t('labelBillable')}</label>
  669. <div class="entry-form__field">
  670. <label class="crud-checkbox-label">
  671. <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
  672. <span>${t('billableLabel')}</span>
  673. </label>
  674. </div>
  675. <label class="entry-form__label">${t('labelRate')}</label>
  676. <div class="entry-form__field entry-form__field--rate">
  677. <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
  678. <span class="entry-form__unit">€</span>
  679. </div>
  680. <label class="entry-form__label">${t('labelNote')}</label>
  681. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  682. }
  683. return `
  684. <div class="crud-row crud-row--new"
  685. id="${prefix}-${data.id}"
  686. data-id="${data.id}"
  687. data-archived="0"
  688. data-name="${esc(data.name)}"
  689. ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''}
  690. ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
  691. ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
  692. data-note="${esc(data.note ?? '')}"
  693. data-lexoffice-contact-id="${esc(data.lexofficeContactId ?? '')}">
  694. <div class="crud-row__display">
  695. <div class="crud-row__info">
  696. <span class="crud-row__name">${esc(data.name)}</span>
  697. ${metaHtml}
  698. </div>
  699. <div class="crud-row__actions">
  700. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
  701. <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
  702. </button>
  703. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
  704. <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
  705. </button>
  706. </div>
  707. </div>
  708. <div class="crud-row__edit" hidden>
  709. <div class="entry-form__grid entry-form__grid--inline">
  710. ${editFields}
  711. <div class="entry-form__actions">
  712. <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
  713. <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
  714. </div>
  715. </div>
  716. </div>
  717. </div>`;
  718. }
  719. // ── Init ─────────────────────────────────────────────────────────────────────
  720. document.addEventListener('DOMContentLoaded', () => {
  721. initCreateForm();
  722. initList();
  723. initTabs();
  724. initRateModeToggles();
  725. initLexofficeCreateToggle();
  726. initLexofficeEditToggles();
  727. });