Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 
 

535 rader
20 KiB

  1. // assets/scripts/crud.js
  2. import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js';
  3. const api = window.CRUD?.apiBase ?? '';
  4. const t = createTranslator('CRUD');
  5. // ── Hilfsfunktionen ──────────────────────────────────────────────────────────
  6. function buildClientOptions(selectedId = null) {
  7. const clients = window.CRUD?.clients ?? [];
  8. let html = `<option value="">${t('selectPh')}</option>`;
  9. clients.forEach(c => {
  10. const sel = String(c.id) === String(selectedId) ? ' selected' : '';
  11. html += `<option value="${c.id}"${sel}>${esc(c.name)}</option>`;
  12. });
  13. return html;
  14. }
  15. function rowPrefix() {
  16. if (location.pathname.includes('/clients')) return 'client';
  17. if (location.pathname.includes('/projects')) return 'project';
  18. if (location.pathname.includes('/services')) return 'service';
  19. return 'row';
  20. }
  21. // ── Rate-Mode Radio Toggle ───────────────────────────────────────────────────
  22. function initRateModeToggles() {
  23. document.addEventListener('change', e => {
  24. if (e.target.type !== 'radio') return;
  25. const container = e.target.closest('.rate-mode');
  26. if (!container) return;
  27. const inputWrap = container.querySelector('.rate-mode__input');
  28. if (!inputWrap) return;
  29. inputWrap.hidden = e.target.value !== 'custom';
  30. if (e.target.value === 'custom') {
  31. inputWrap.querySelector('input')?.focus();
  32. }
  33. });
  34. }
  35. // ── Create-Formular ──────────────────────────────────────────────────────────
  36. function initCreateForm() {
  37. const btnNew = document.getElementById('btn-new');
  38. const form = document.getElementById('crud-create');
  39. const btnSave = document.getElementById('btn-create-save');
  40. const btnCancel = document.getElementById('btn-create-cancel');
  41. if (!btnNew || !form) return;
  42. btnNew.addEventListener('click', () => {
  43. form.classList.toggle('crud-create--visible');
  44. document.getElementById('create-name')?.focus();
  45. });
  46. btnCancel?.addEventListener('click', () => {
  47. form.classList.remove('crud-create--visible');
  48. resetCreateForm();
  49. });
  50. btnSave?.addEventListener('click', () => createEntity());
  51. }
  52. function resetCreateForm() {
  53. ['create-name', 'create-note'].forEach(id => {
  54. const el = document.getElementById(id);
  55. if (el) el.value = '';
  56. });
  57. const rate = document.getElementById('create-rate');
  58. if (rate) rate.value = '';
  59. const billable = document.getElementById('create-billable');
  60. if (billable) billable.checked = true;
  61. const client = document.getElementById('create-client');
  62. if (client) client.value = '';
  63. const defaultRadio = document.querySelector('[name="create-rate-mode"][value="default"]');
  64. if (defaultRadio) {
  65. defaultRadio.checked = true;
  66. const rateInput = defaultRadio.closest('.rate-mode')?.querySelector('.rate-mode__input');
  67. if (rateInput) rateInput.hidden = true;
  68. }
  69. }
  70. async function createEntity() {
  71. const name = document.getElementById('create-name')?.value?.trim();
  72. if (!name) { alert(t('errorNoName')); return; }
  73. const btn = document.getElementById('btn-create-save');
  74. const body = buildCreateBody();
  75. if (btn) btn.disabled = true;
  76. try {
  77. const res = await fetch(api, {
  78. method: 'POST',
  79. headers: { 'Content-Type': 'application/json' },
  80. body: JSON.stringify(body),
  81. });
  82. if (!res.ok) {
  83. const err = await res.json().catch(() => ({}));
  84. alert(err.error ?? t('errorSave'));
  85. return;
  86. }
  87. const data = await res.json();
  88. appendRowToList(data);
  89. document.getElementById('crud-create')?.classList.remove('crud-create--visible');
  90. resetCreateForm();
  91. } catch {
  92. alert(t('errorSave'));
  93. } finally {
  94. if (btn) btn.disabled = false;
  95. }
  96. }
  97. function buildCreateBody() {
  98. const body = {
  99. name: document.getElementById('create-name')?.value?.trim(),
  100. note: document.getElementById('create-note')?.value || null,
  101. };
  102. const rateMode = document.querySelector('[name="create-rate-mode"]:checked');
  103. const rate = document.getElementById('create-rate');
  104. if (rateMode) {
  105. body.hourlyRate = rateMode.value === 'custom' && rate?.value ? rate.value : null;
  106. } else if (rate) {
  107. body.hourlyRate = rate.value || null;
  108. }
  109. const client = document.getElementById('create-client');
  110. if (client) body.clientId = parseInt(client.value, 10) || null;
  111. const billable = document.getElementById('create-billable');
  112. if (billable) body.billable = billable.checked;
  113. return body;
  114. }
  115. // ── Liste: Event Delegation ──────────────────────────────────────────────────
  116. function initList() {
  117. const list = document.getElementById('crud-list');
  118. if (!list) return;
  119. list.addEventListener('click', e => {
  120. const actionEl = e.target.closest('[data-action]');
  121. if (!actionEl) return;
  122. const row = e.target.closest('.crud-row');
  123. if (!row) return;
  124. switch (actionEl.dataset.action) {
  125. case 'edit': openEdit(row); break;
  126. case 'delete': deleteRow(row); break;
  127. case 'save': saveEdit(row); break;
  128. case 'cancel': closeEdit(row); break;
  129. case 'unarchive': unarchiveRow(row); break;
  130. }
  131. });
  132. }
  133. // ── Inline Edit ──────────────────────────────────────────────────────────────
  134. function openEdit(row) {
  135. row.querySelector('.crud-row__display').hidden = true;
  136. row.querySelector('.crud-row__edit').hidden = false;
  137. row.querySelector('.edit-name')?.focus();
  138. }
  139. function closeEdit(row) {
  140. row.querySelector('.crud-row__display').hidden = false;
  141. row.querySelector('.crud-row__edit').hidden = true;
  142. }
  143. async function saveEdit(row) {
  144. const saveBtn = row.querySelector('[data-action="save"]');
  145. if (saveBtn?.disabled) return;
  146. const name = row.querySelector('.edit-name')?.value?.trim();
  147. if (!name) { alert(t('errorNoName')); return; }
  148. const body = buildEditBody(row);
  149. if (saveBtn) saveBtn.disabled = true;
  150. try {
  151. const res = await fetch(`${api}/${row.dataset.id}`, {
  152. method: 'PATCH',
  153. headers: { 'Content-Type': 'application/json' },
  154. body: JSON.stringify(body),
  155. });
  156. if (!res.ok) { alert(t('errorSave')); return; }
  157. const data = await res.json();
  158. updateRowDisplay(row, data);
  159. closeEdit(row);
  160. } catch {
  161. alert(t('errorSave'));
  162. } finally {
  163. if (saveBtn) saveBtn.disabled = false;
  164. }
  165. }
  166. function buildEditBody(row) {
  167. const body = {
  168. name: row.querySelector('.edit-name')?.value?.trim(),
  169. note: row.querySelector('.edit-note')?.value || null,
  170. };
  171. const rateMode = row.querySelector('.rate-mode input[type="radio"][value="custom"]');
  172. const rate = row.querySelector('.edit-rate');
  173. if (rateMode) {
  174. body.hourlyRate = rateMode.checked && rate?.value ? rate.value : null;
  175. } else if (rate) {
  176. body.hourlyRate = rate.value || null;
  177. }
  178. const client = row.querySelector('.edit-client');
  179. if (client) body.clientId = parseInt(client.value, 10) || null;
  180. const billable = row.querySelector('.edit-billable');
  181. if (billable) body.billable = billable.checked;
  182. return body;
  183. }
  184. function updateRowDisplay(row, data) {
  185. const nameEl = row.querySelector('.crud-row__name');
  186. const metaEl = row.querySelector('.crud-row__meta');
  187. if (nameEl) nameEl.textContent = data.name;
  188. if (data.clientName && metaEl) metaEl.textContent = data.clientName;
  189. row.dataset.name = data.name;
  190. if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
  191. if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
  192. if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
  193. if (data.note !== undefined) row.dataset.note = data.note ?? '';
  194. const editName = row.querySelector('.edit-name');
  195. if (editName) editName.value = data.name;
  196. const editNote = row.querySelector('.edit-note');
  197. if (editNote) editNote.value = data.note ?? '';
  198. const editRate = row.querySelector('.edit-rate');
  199. if (editRate) editRate.value = data.hourlyRate ?? '';
  200. const rateMode = row.querySelector('.rate-mode');
  201. if (rateMode) {
  202. const hasCustom = data.hourlyRate != null && data.hourlyRate !== '';
  203. const defaultRadio = rateMode.querySelector('input[value="default"]');
  204. const customRadio = rateMode.querySelector('input[value="custom"]');
  205. const inputWrap = rateMode.querySelector('.rate-mode__input');
  206. if (defaultRadio) defaultRadio.checked = !hasCustom;
  207. if (customRadio) customRadio.checked = hasCustom;
  208. if (inputWrap) inputWrap.hidden = !hasCustom;
  209. }
  210. const editBillable = row.querySelector('.edit-billable');
  211. if (editBillable) editBillable.checked = !!data.billable;
  212. }
  213. // ── Delete ───────────────────────────────────────────────────────────────────
  214. async function deleteRow(row) {
  215. if (!confirm(t('confirmDelete'))) return;
  216. try {
  217. const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });
  218. if (res.status === 409) {
  219. if (confirm(t('confirmArchive'))) {
  220. await archiveRow(row);
  221. }
  222. return;
  223. }
  224. if (!res.ok) { alert(t('errorDelete')); return; }
  225. removeWithAnimation(row, 'crud-row--removing');
  226. } catch {
  227. alert(t('errorDelete'));
  228. }
  229. }
  230. async function archiveRow(row) {
  231. try {
  232. const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
  233. if (!res.ok) { alert(t('errorArchive')); return; }
  234. row.dataset.archived = '1';
  235. row.classList.add('crud-row--archived');
  236. updateRowArchivedState(row, true);
  237. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  238. } catch {
  239. alert(t('errorArchive'));
  240. }
  241. }
  242. async function unarchiveRow(row) {
  243. try {
  244. const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
  245. if (!res.ok) { alert(t('errorRestore')); return; }
  246. row.dataset.archived = '0';
  247. row.classList.remove('crud-row--archived');
  248. updateRowArchivedState(row, false);
  249. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  250. } catch {
  251. alert(t('errorRestore'));
  252. }
  253. }
  254. function updateRowArchivedState(row, archived) {
  255. const actions = row.querySelector('.crud-row__actions');
  256. if (!actions) return;
  257. if (archived) {
  258. actions.innerHTML = `
  259. <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="${t('btnRestore')}">
  260. <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>
  261. </button>`;
  262. row.querySelector('.crud-row__edit')?.remove();
  263. } else {
  264. actions.innerHTML = `
  265. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
  266. <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>
  267. </button>
  268. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
  269. <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>
  270. </button>`;
  271. }
  272. }
  273. function filterByTab(tab) {
  274. document.querySelectorAll('#crud-list .crud-row').forEach(row => {
  275. row.hidden = tab === 'active'
  276. ? row.dataset.archived === '1'
  277. : row.dataset.archived === '0';
  278. });
  279. }
  280. function initTabs() {
  281. const tabs = document.querySelectorAll('.crud-tab');
  282. if (!tabs.length) return;
  283. filterByTab('active');
  284. tabs.forEach(tab => {
  285. tab.addEventListener('click', () => {
  286. tabs.forEach(t => t.classList.remove('crud-tab--active'));
  287. tab.classList.add('crud-tab--active');
  288. filterByTab(tab.dataset.tab);
  289. const btnNew = document.getElementById('btn-new');
  290. if (btnNew) btnNew.hidden = tab.dataset.tab === 'archived';
  291. });
  292. });
  293. }
  294. // ── Neue Zeile einfügen ──────────────────────────────────────────────────────
  295. function appendRowToList(data) {
  296. const list = document.getElementById('crud-list');
  297. if (!list) return;
  298. const html = buildRowHTML(data);
  299. if (data.billable !== undefined) {
  300. const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable');
  301. let targetGroup = null;
  302. list.querySelectorAll('.crud-list__group').forEach(g => {
  303. if (g.querySelector('.crud-list__group-label')?.textContent === groupLabel) {
  304. targetGroup = g;
  305. }
  306. });
  307. if (targetGroup) {
  308. targetGroup.insertAdjacentHTML('beforeend', html);
  309. } else {
  310. const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${esc(groupLabel)}</div>${html}</div>`;
  311. if (!data.billable) {
  312. list.insertAdjacentHTML('beforeend', groupHtml);
  313. } else {
  314. const firstGroup = list.querySelector('.crud-list__group');
  315. firstGroup
  316. ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
  317. : list.insertAdjacentHTML('beforeend', groupHtml);
  318. }
  319. }
  320. } else {
  321. list.insertAdjacentHTML('beforeend', html);
  322. }
  323. const prefix = rowPrefix();
  324. const el = document.getElementById(`${prefix}-${data.id}`);
  325. if (el) animateIn(el, 'crud-row--new');
  326. }
  327. function buildRowHTML(data) {
  328. const prefix = rowPrefix();
  329. let metaHtml = '';
  330. let editFields = '';
  331. if (data.projectCount !== undefined) {
  332. const c = data.projectCount;
  333. const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== '';
  334. metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`;
  335. editFields = `
  336. <label class="entry-form__label">${t('labelName')}</label>
  337. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  338. <label class="entry-form__label">${t('labelRate')}</label>
  339. <div class="entry-form__field">
  340. <div class="rate-mode">
  341. <label class="rate-mode__option">
  342. <input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} />
  343. <span>${t('rateModeDefault')}</span>
  344. </label>
  345. <label class="rate-mode__option">
  346. <input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} />
  347. <span>${t('rateModeCustom')}</span>
  348. </label>
  349. <div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}>
  350. <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
  351. <span class="entry-form__unit">€</span>
  352. </div>
  353. </div>
  354. </div>
  355. <label class="entry-form__label">${t('labelNote')}</label>
  356. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  357. }
  358. if (data.clientName !== undefined && data.projectCount === undefined) {
  359. const hasCustomRate = data.hourlyRate != null && data.hourlyRate !== '';
  360. metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`;
  361. editFields = `
  362. <label class="entry-form__label">${t('labelName')}</label>
  363. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  364. <label class="entry-form__label">${t('labelClient')}</label>
  365. <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
  366. <label class="entry-form__label">${t('labelRate')}</label>
  367. <div class="entry-form__field">
  368. <div class="rate-mode">
  369. <label class="rate-mode__option">
  370. <input type="radio" name="edit-rate-mode-${data.id}" value="default" ${!hasCustomRate ? 'checked' : ''} />
  371. <span>${t('rateModeDefault')}</span>
  372. </label>
  373. <label class="rate-mode__option">
  374. <input type="radio" name="edit-rate-mode-${data.id}" value="custom" ${hasCustomRate ? 'checked' : ''} />
  375. <span>${t('rateModeCustom')}</span>
  376. </label>
  377. <div class="rate-mode__input"${!hasCustomRate ? ' hidden' : ''}>
  378. <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
  379. <span class="entry-form__unit">€</span>
  380. </div>
  381. </div>
  382. </div>
  383. <label class="entry-form__label">${t('labelNote')}</label>
  384. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  385. }
  386. if (data.billable !== undefined) {
  387. editFields = `
  388. <label class="entry-form__label">${t('labelName')}</label>
  389. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  390. <label class="entry-form__label">${t('labelBillable')}</label>
  391. <div class="entry-form__field">
  392. <label class="crud-checkbox-label">
  393. <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
  394. <span>${t('billableLabel')}</span>
  395. </label>
  396. </div>
  397. <label class="entry-form__label">${t('labelRate')}</label>
  398. <div class="entry-form__field entry-form__field--rate">
  399. <input type="number" class="input input--rate edit-rate" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
  400. <span class="entry-form__unit">€</span>
  401. </div>
  402. <label class="entry-form__label">${t('labelNote')}</label>
  403. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  404. }
  405. return `
  406. <div class="crud-row crud-row--new"
  407. id="${prefix}-${data.id}"
  408. data-id="${data.id}"
  409. data-archived="0"
  410. data-name="${esc(data.name)}"
  411. ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''}
  412. ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
  413. ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
  414. data-note="${esc(data.note ?? '')}">
  415. <div class="crud-row__display">
  416. <div class="crud-row__info">
  417. <span class="crud-row__name">${esc(data.name)}</span>
  418. ${metaHtml}
  419. </div>
  420. <div class="crud-row__actions">
  421. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
  422. <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>
  423. </button>
  424. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
  425. <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>
  426. </button>
  427. </div>
  428. </div>
  429. <div class="crud-row__edit" hidden>
  430. <div class="entry-form__grid entry-form__grid--inline">
  431. ${editFields}
  432. <div class="entry-form__actions">
  433. <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
  434. <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
  435. </div>
  436. </div>
  437. </div>
  438. </div>`;
  439. }
  440. // ── Init ─────────────────────────────────────────────────────────────────────
  441. document.addEventListener('DOMContentLoaded', () => {
  442. initCreateForm();
  443. initList();
  444. initTabs();
  445. initRateModeToggles();
  446. });