Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 
 

452 рядки
17 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. // ── Create-Formular ──────────────────────────────────────────────────────────
  22. function initCreateForm() {
  23. const btnNew = document.getElementById('btn-new');
  24. const form = document.getElementById('crud-create');
  25. const btnSave = document.getElementById('btn-create-save');
  26. const btnCancel = document.getElementById('btn-create-cancel');
  27. if (!btnNew || !form) return;
  28. btnNew.addEventListener('click', () => {
  29. form.classList.toggle('crud-create--visible');
  30. document.getElementById('create-name')?.focus();
  31. });
  32. btnCancel?.addEventListener('click', () => {
  33. form.classList.remove('crud-create--visible');
  34. resetCreateForm();
  35. });
  36. btnSave?.addEventListener('click', () => createEntity());
  37. }
  38. function resetCreateForm() {
  39. ['create-name', 'create-note'].forEach(id => {
  40. const el = document.getElementById(id);
  41. if (el) el.value = '';
  42. });
  43. const rate = document.getElementById('create-rate');
  44. if (rate) rate.value = '';
  45. const billable = document.getElementById('create-billable');
  46. if (billable) billable.checked = true;
  47. const client = document.getElementById('create-client');
  48. if (client) client.value = '';
  49. }
  50. async function createEntity() {
  51. const name = document.getElementById('create-name')?.value?.trim();
  52. if (!name) { alert(t('errorNoName')); return; }
  53. const btn = document.getElementById('btn-create-save');
  54. const body = buildCreateBody();
  55. if (btn) btn.disabled = true;
  56. try {
  57. const res = await fetch(api, {
  58. method: 'POST',
  59. headers: { 'Content-Type': 'application/json' },
  60. body: JSON.stringify(body),
  61. });
  62. if (!res.ok) {
  63. const err = await res.json().catch(() => ({}));
  64. alert(err.error ?? t('errorSave'));
  65. return;
  66. }
  67. const data = await res.json();
  68. appendRowToList(data);
  69. document.getElementById('crud-create')?.classList.remove('crud-create--visible');
  70. resetCreateForm();
  71. } catch {
  72. alert(t('errorSave'));
  73. } finally {
  74. if (btn) btn.disabled = false;
  75. }
  76. }
  77. function buildCreateBody() {
  78. const body = {
  79. name: document.getElementById('create-name')?.value?.trim(),
  80. note: document.getElementById('create-note')?.value || null,
  81. };
  82. const rate = document.getElementById('create-rate');
  83. if (rate) body.hourlyRate = rate.value || null;
  84. const client = document.getElementById('create-client');
  85. if (client) body.clientId = parseInt(client.value, 10) || null;
  86. const billable = document.getElementById('create-billable');
  87. if (billable) body.billable = billable.checked;
  88. return body;
  89. }
  90. // ── Liste: Event Delegation ──────────────────────────────────────────────────
  91. function initList() {
  92. const list = document.getElementById('crud-list');
  93. if (!list) return;
  94. list.addEventListener('click', e => {
  95. const actionEl = e.target.closest('[data-action]');
  96. if (!actionEl) return;
  97. const row = e.target.closest('.crud-row');
  98. if (!row) return;
  99. switch (actionEl.dataset.action) {
  100. case 'edit': openEdit(row); break;
  101. case 'delete': deleteRow(row); break;
  102. case 'save': saveEdit(row); break;
  103. case 'cancel': closeEdit(row); break;
  104. case 'unarchive': unarchiveRow(row); break;
  105. }
  106. });
  107. }
  108. // ── Inline Edit ──────────────────────────────────────────────────────────────
  109. function openEdit(row) {
  110. row.querySelector('.crud-row__display').hidden = true;
  111. row.querySelector('.crud-row__edit').hidden = false;
  112. row.querySelector('.edit-name')?.focus();
  113. }
  114. function closeEdit(row) {
  115. row.querySelector('.crud-row__display').hidden = false;
  116. row.querySelector('.crud-row__edit').hidden = true;
  117. }
  118. async function saveEdit(row) {
  119. const saveBtn = row.querySelector('[data-action="save"]');
  120. if (saveBtn?.disabled) return;
  121. const name = row.querySelector('.edit-name')?.value?.trim();
  122. if (!name) { alert(t('errorNoName')); return; }
  123. const body = buildEditBody(row);
  124. if (saveBtn) saveBtn.disabled = true;
  125. try {
  126. const res = await fetch(`${api}/${row.dataset.id}`, {
  127. method: 'PATCH',
  128. headers: { 'Content-Type': 'application/json' },
  129. body: JSON.stringify(body),
  130. });
  131. if (!res.ok) { alert(t('errorSave')); return; }
  132. const data = await res.json();
  133. updateRowDisplay(row, data);
  134. closeEdit(row);
  135. } catch {
  136. alert(t('errorSave'));
  137. } finally {
  138. if (saveBtn) saveBtn.disabled = false;
  139. }
  140. }
  141. function buildEditBody(row) {
  142. const body = {
  143. name: row.querySelector('.edit-name')?.value?.trim(),
  144. note: row.querySelector('.edit-note')?.value || null,
  145. };
  146. const rate = row.querySelector('.edit-rate');
  147. if (rate) body.hourlyRate = rate.value || null;
  148. const client = row.querySelector('.edit-client');
  149. if (client) body.clientId = parseInt(client.value, 10) || null;
  150. const billable = row.querySelector('.edit-billable');
  151. if (billable) body.billable = billable.checked;
  152. return body;
  153. }
  154. function updateRowDisplay(row, data) {
  155. const nameEl = row.querySelector('.crud-row__name');
  156. const metaEl = row.querySelector('.crud-row__meta');
  157. if (nameEl) nameEl.textContent = data.name;
  158. if (data.clientName && metaEl) metaEl.textContent = data.clientName;
  159. row.dataset.name = data.name;
  160. if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
  161. if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
  162. if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
  163. if (data.note !== undefined) row.dataset.note = data.note ?? '';
  164. const editName = row.querySelector('.edit-name');
  165. if (editName) editName.value = data.name;
  166. const editNote = row.querySelector('.edit-note');
  167. if (editNote) editNote.value = data.note ?? '';
  168. const editRate = row.querySelector('.edit-rate');
  169. if (editRate) editRate.value = data.hourlyRate ?? '';
  170. const editBillable = row.querySelector('.edit-billable');
  171. if (editBillable) editBillable.checked = !!data.billable;
  172. }
  173. // ── Delete ───────────────────────────────────────────────────────────────────
  174. async function deleteRow(row) {
  175. if (!confirm(t('confirmDelete'))) return;
  176. try {
  177. const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });
  178. if (res.status === 409) {
  179. if (confirm(t('confirmArchive'))) {
  180. await archiveRow(row);
  181. }
  182. return;
  183. }
  184. if (!res.ok) { alert(t('errorDelete')); return; }
  185. removeWithAnimation(row, 'crud-row--removing');
  186. } catch {
  187. alert(t('errorDelete'));
  188. }
  189. }
  190. async function archiveRow(row) {
  191. try {
  192. const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
  193. if (!res.ok) { alert(t('errorArchive')); return; }
  194. row.dataset.archived = '1';
  195. row.classList.add('crud-row--archived');
  196. updateRowArchivedState(row, true);
  197. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  198. } catch {
  199. alert(t('errorArchive'));
  200. }
  201. }
  202. async function unarchiveRow(row) {
  203. try {
  204. const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
  205. if (!res.ok) { alert(t('errorRestore')); return; }
  206. row.dataset.archived = '0';
  207. row.classList.remove('crud-row--archived');
  208. updateRowArchivedState(row, false);
  209. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  210. } catch {
  211. alert(t('errorRestore'));
  212. }
  213. }
  214. function updateRowArchivedState(row, archived) {
  215. const actions = row.querySelector('.crud-row__actions');
  216. if (!actions) return;
  217. if (archived) {
  218. actions.innerHTML = `
  219. <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="${t('btnRestore')}">
  220. <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>
  221. </button>`;
  222. row.querySelector('.crud-row__edit')?.remove();
  223. } else {
  224. actions.innerHTML = `
  225. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
  226. <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>
  227. </button>
  228. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
  229. <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>
  230. </button>`;
  231. }
  232. }
  233. function filterByTab(tab) {
  234. document.querySelectorAll('#crud-list .crud-row').forEach(row => {
  235. row.hidden = tab === 'active'
  236. ? row.dataset.archived === '1'
  237. : row.dataset.archived === '0';
  238. });
  239. }
  240. function initTabs() {
  241. const tabs = document.querySelectorAll('.crud-tab');
  242. if (!tabs.length) return;
  243. filterByTab('active');
  244. tabs.forEach(tab => {
  245. tab.addEventListener('click', () => {
  246. tabs.forEach(t => t.classList.remove('crud-tab--active'));
  247. tab.classList.add('crud-tab--active');
  248. filterByTab(tab.dataset.tab);
  249. const btnNew = document.getElementById('btn-new');
  250. if (btnNew) btnNew.hidden = tab.dataset.tab === 'archived';
  251. });
  252. });
  253. }
  254. // ── Neue Zeile einfügen ──────────────────────────────────────────────────────
  255. function appendRowToList(data) {
  256. const list = document.getElementById('crud-list');
  257. if (!list) return;
  258. const html = buildRowHTML(data);
  259. if (data.billable !== undefined) {
  260. const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable');
  261. let targetGroup = null;
  262. list.querySelectorAll('.crud-list__group').forEach(g => {
  263. if (g.querySelector('.crud-list__group-label')?.textContent === groupLabel) {
  264. targetGroup = g;
  265. }
  266. });
  267. if (targetGroup) {
  268. targetGroup.insertAdjacentHTML('beforeend', html);
  269. } else {
  270. const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${esc(groupLabel)}</div>${html}</div>`;
  271. if (!data.billable) {
  272. list.insertAdjacentHTML('beforeend', groupHtml);
  273. } else {
  274. const firstGroup = list.querySelector('.crud-list__group');
  275. firstGroup
  276. ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
  277. : list.insertAdjacentHTML('beforeend', groupHtml);
  278. }
  279. }
  280. } else {
  281. list.insertAdjacentHTML('beforeend', html);
  282. }
  283. const prefix = rowPrefix();
  284. const el = document.getElementById(`${prefix}-${data.id}`);
  285. if (el) animateIn(el, 'crud-row--new');
  286. }
  287. function buildRowHTML(data) {
  288. const prefix = rowPrefix();
  289. let metaHtml = '';
  290. let editFields = '';
  291. if (data.projectCount !== undefined) {
  292. const c = data.projectCount;
  293. metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`;
  294. editFields = `
  295. <label class="entry-form__label">${t('labelName')}</label>
  296. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  297. <label class="entry-form__label">${t('labelRate')}</label>
  298. <div class="entry-form__field" style="gap:8px">
  299. <input type="number" class="input edit-rate" style="width:100px" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
  300. <span style="color:#7a8a9a;font-size:0.875rem">€</span>
  301. </div>
  302. <label class="entry-form__label">${t('labelNote')}</label>
  303. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  304. }
  305. if (data.clientName !== undefined && data.projectCount === undefined) {
  306. metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`;
  307. editFields = `
  308. <label class="entry-form__label">${t('labelName')}</label>
  309. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  310. <label class="entry-form__label">${t('labelClient')}</label>
  311. <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
  312. <label class="entry-form__label">${t('labelNote')}</label>
  313. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  314. }
  315. if (data.billable !== undefined) {
  316. editFields = `
  317. <label class="entry-form__label">${t('labelName')}</label>
  318. <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
  319. <label class="entry-form__label">${t('labelBillable')}</label>
  320. <div class="entry-form__field">
  321. <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
  322. <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
  323. <span style="font-size:0.875rem">${t('billableLabel')}</span>
  324. </label>
  325. </div>
  326. <label class="entry-form__label">${t('labelNote')}</label>
  327. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
  328. }
  329. return `
  330. <div class="crud-row crud-row--new"
  331. id="${prefix}-${data.id}"
  332. data-id="${data.id}"
  333. data-archived="0"
  334. data-name="${esc(data.name)}"
  335. ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''}
  336. ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
  337. ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
  338. data-note="${esc(data.note ?? '')}">
  339. <div class="crud-row__display">
  340. <div class="crud-row__info">
  341. <span class="crud-row__name">${esc(data.name)}</span>
  342. ${metaHtml}
  343. </div>
  344. <div class="crud-row__actions">
  345. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
  346. <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>
  347. </button>
  348. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="${t('btnDelete')}">
  349. <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>
  350. </button>
  351. </div>
  352. </div>
  353. <div class="crud-row__edit" hidden>
  354. <div class="entry-form__grid entry-form__grid--inline">
  355. ${editFields}
  356. <div class="entry-form__actions">
  357. <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
  358. <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
  359. </div>
  360. </div>
  361. </div>
  362. </div>`;
  363. }
  364. // ── Init ─────────────────────────────────────────────────────────────────────
  365. document.addEventListener('DOMContentLoaded', () => {
  366. initCreateForm();
  367. initList();
  368. initTabs();
  369. });