Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 
 

474 строки
17 KiB

  1. // assets/scripts/crud.js
  2. // Generisches CRUD-Handler für Kunden, Projekte, Leistungen
  3. const api = window.CRUD?.apiBase ?? '';
  4. // ── Hilfsfunktionen ───────────────────────────────────────────────────────────
  5. function buildClientOptions(selectedId = null) {
  6. const clients = window.CRUD?.clients ?? [];
  7. let html = '<option value="">Bitte wählen</option>';
  8. clients.forEach(c => {
  9. const sel = String(c.id) === String(selectedId) ? ' selected' : '';
  10. html += `<option value="${c.id}"${sel}>${c.name}</option>`;
  11. });
  12. return html;
  13. }
  14. function rowPrefix() {
  15. // Ermittelt den Entitätstyp aus der URL
  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. const fields = ['create-name', 'create-note'];
  40. fields.forEach(id => {
  41. const el = document.getElementById(id);
  42. if (el) el.value = '';
  43. });
  44. const rate = document.getElementById('create-rate');
  45. if (rate) rate.value = '';
  46. const billable = document.getElementById('create-billable');
  47. if (billable) billable.checked = true;
  48. const client = document.getElementById('create-client');
  49. if (client) client.value = '';
  50. }
  51. async function createEntity() {
  52. const name = document.getElementById('create-name')?.value?.trim();
  53. if (!name) { alert('Bitte einen Namen eingeben.'); return; }
  54. const body = buildCreateBody();
  55. try {
  56. const res = await fetch(api, {
  57. method: 'POST',
  58. headers: { 'Content-Type': 'application/json' },
  59. body: JSON.stringify(body),
  60. });
  61. if (!res.ok) {
  62. const err = await res.json().catch(() => ({}));
  63. alert(err.error ?? 'Fehler beim Speichern.');
  64. return;
  65. }
  66. const data = await res.json();
  67. appendRowToList(data);
  68. document.getElementById('crud-create')?.classList.remove('crud-create--visible');
  69. resetCreateForm();
  70. } catch (err) {
  71. console.error(err);
  72. alert('Fehler beim Speichern.');
  73. }
  74. }
  75. function buildCreateBody() {
  76. const body = {
  77. name: document.getElementById('create-name')?.value?.trim(),
  78. note: document.getElementById('create-note')?.value || null,
  79. };
  80. // Kunden-spezifisch
  81. const rate = document.getElementById('create-rate');
  82. if (rate) body.hourlyRate = rate.value || null;
  83. // Projekt-spezifisch
  84. const client = document.getElementById('create-client');
  85. if (client) body.clientId = parseInt(client.value) || null;
  86. // Leistungs-spezifisch
  87. const billable = document.getElementById('create-billable');
  88. if (billable) body.billable = billable.checked;
  89. return body;
  90. }
  91. // ── Liste: Event Delegation ────────────────────────────────────────────────────
  92. function initList() {
  93. const list = document.getElementById('crud-list');
  94. if (!list) return;
  95. list.addEventListener('click', e => {
  96. const actionEl = e.target.closest('[data-action]');
  97. if (!actionEl) return;
  98. const action = actionEl.dataset.action;
  99. const row = e.target.closest('.crud-row');
  100. if (!row) return;
  101. switch (action) {
  102. case 'edit': openEdit(row); break;
  103. case 'delete': deleteRow(row); break;
  104. case 'save': saveEdit(row); break;
  105. case 'cancel': closeEdit(row); break;
  106. case 'unarchive': unarchiveRow(row); break;
  107. }
  108. });
  109. }
  110. // ── Inline Edit ───────────────────────────────────────────────────────────────
  111. function openEdit(row) {
  112. row.querySelector('.crud-row__display').hidden = true;
  113. row.querySelector('.crud-row__edit').hidden = false;
  114. row.querySelector('.edit-name')?.focus();
  115. }
  116. function closeEdit(row) {
  117. row.querySelector('.crud-row__display').hidden = false;
  118. row.querySelector('.crud-row__edit').hidden = true;
  119. }
  120. async function saveEdit(row) {
  121. const id = row.dataset.id;
  122. const name = row.querySelector('.edit-name')?.value?.trim();
  123. if (!name) { alert('Bitte einen Namen eingeben.'); return; }
  124. const body = buildEditBody(row);
  125. try {
  126. const res = await fetch(`${api}/${id}`, {
  127. method: 'PATCH',
  128. headers: { 'Content-Type': 'application/json' },
  129. body: JSON.stringify(body),
  130. });
  131. if (!res.ok) { alert('Fehler beim Speichern.'); return; }
  132. const data = await res.json();
  133. updateRowDisplay(row, data);
  134. closeEdit(row);
  135. } catch (err) {
  136. console.error(err);
  137. alert('Fehler beim Speichern.');
  138. }
  139. }
  140. function buildEditBody(row) {
  141. const body = {
  142. name: row.querySelector('.edit-name')?.value?.trim(),
  143. note: row.querySelector('.edit-note')?.value || null,
  144. };
  145. // Kunden
  146. const rate = row.querySelector('.edit-rate');
  147. if (rate) body.hourlyRate = rate.value || null;
  148. // Projekt
  149. const client = row.querySelector('.edit-client');
  150. if (client) body.clientId = parseInt(client.value) || null;
  151. // Leistung
  152. const billable = row.querySelector('.edit-billable');
  153. if (billable) body.billable = billable.checked;
  154. return body;
  155. }
  156. function updateRowDisplay(row, data) {
  157. const nameEl = row.querySelector('.crud-row__name');
  158. const metaEl = row.querySelector('.crud-row__meta');
  159. if (nameEl) nameEl.textContent = data.name;
  160. // Kunden: Meta-Text unverändert (Projektanzahl ändert sich nicht)
  161. // Projekte: Client-Name aktualisieren
  162. if (data.clientName && metaEl) metaEl.textContent = data.clientName;
  163. // data-Attribute aktualisieren
  164. row.dataset.name = data.name;
  165. if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
  166. if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
  167. if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
  168. if (data.note !== undefined) row.dataset.note = data.note ?? '';
  169. // Edit-Felder aktualisieren
  170. const editName = row.querySelector('.edit-name');
  171. if (editName) editName.value = data.name;
  172. const editNote = row.querySelector('.edit-note');
  173. if (editNote) editNote.value = data.note ?? '';
  174. const editRate = row.querySelector('.edit-rate');
  175. if (editRate) editRate.value = data.hourlyRate ?? '';
  176. const editBillable = row.querySelector('.edit-billable');
  177. if (editBillable) editBillable.checked = !!data.billable;
  178. }
  179. // ── Delete ────────────────────────────────────────────────────────────────────
  180. async function deleteRow(row) {
  181. if (!confirm('Wirklich löschen?')) return;
  182. try {
  183. const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });
  184. if (res.status === 409) {
  185. if (confirm('Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) {
  186. await archiveRow(row);
  187. }
  188. return;
  189. }
  190. if (!res.ok) { alert('Fehler beim Löschen.'); return; }
  191. row.classList.add('crud-row--removing');
  192. setTimeout(() => row.remove(), 280);
  193. } catch {
  194. alert('Fehler beim Löschen.');
  195. }
  196. }
  197. async function archiveRow(row) {
  198. try {
  199. const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
  200. if (!res.ok) { alert('Fehler beim Archivieren.'); return; }
  201. row.dataset.archived = '1';
  202. row.classList.add('crud-row--archived');
  203. updateRowArchivedState(row, true);
  204. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  205. } catch {
  206. alert('Fehler beim Archivieren.');
  207. }
  208. }
  209. async function unarchiveRow(row) {
  210. try {
  211. const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
  212. if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; }
  213. row.dataset.archived = '0';
  214. row.classList.remove('crud-row--archived');
  215. updateRowArchivedState(row, false);
  216. filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
  217. } catch {
  218. alert('Fehler beim Wiederherstellen.');
  219. }
  220. }
  221. function updateRowArchivedState(row, archived) {
  222. const actions = row.querySelector('.crud-row__actions');
  223. if (!actions) return;
  224. if (archived) {
  225. actions.innerHTML = `
  226. <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen">
  227. <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>
  228. </button>`;
  229. row.querySelector('.crud-row__edit')?.remove();
  230. } else {
  231. actions.innerHTML = `
  232. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
  233. <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>
  234. </button>
  235. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
  236. <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>
  237. </button>`;
  238. }
  239. }
  240. function filterByTab(tab) {
  241. document.querySelectorAll('#crud-list .crud-row').forEach(row => {
  242. row.hidden = tab === 'active'
  243. ? row.dataset.archived === '1'
  244. : row.dataset.archived === '0';
  245. });
  246. }
  247. function initTabs() {
  248. const tabs = document.querySelectorAll('.crud-tab');
  249. if (!tabs.length) return;
  250. filterByTab('active');
  251. tabs.forEach(tab => {
  252. tab.addEventListener('click', () => {
  253. tabs.forEach(t => t.classList.remove('crud-tab--active'));
  254. tab.classList.add('crud-tab--active');
  255. filterByTab(tab.dataset.tab);
  256. const btnNew = document.getElementById('btn-new');
  257. if (btnNew) btnNew.hidden = tab.dataset.tab === 'archived';
  258. });
  259. });
  260. }
  261. // ── Neue Zeile einfügen ───────────────────────────────────────────────────────
  262. function appendRowToList(data) {
  263. const list = document.getElementById('crud-list');
  264. if (!list) return;
  265. const html = buildRowHTML(data);
  266. // Services haben Gruppen → in die richtige Gruppe einfügen
  267. if (data.billable !== undefined) {
  268. const groupLabel = data.billable ? 'Verrechenbar' : 'Nicht-verrechenbar';
  269. let targetGroup = null;
  270. list.querySelectorAll('.crud-list__group').forEach(g => {
  271. if (g.querySelector('.crud-list__group-label')?.textContent === groupLabel) {
  272. targetGroup = g;
  273. }
  274. });
  275. if (targetGroup) {
  276. targetGroup.insertAdjacentHTML('beforeend', html);
  277. } else {
  278. // Gruppe existiert noch nicht → neu anlegen
  279. const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${groupLabel}</div>${html}</div>`;
  280. if (!data.billable) {
  281. // Nicht-verrechenbar immer ans Ende
  282. list.insertAdjacentHTML('beforeend', groupHtml);
  283. } else {
  284. // Verrechenbar vor die erste existierende Gruppe
  285. const firstGroup = list.querySelector('.crud-list__group');
  286. firstGroup
  287. ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
  288. : list.insertAdjacentHTML('beforeend', groupHtml);
  289. }
  290. }
  291. } else {
  292. list.insertAdjacentHTML('beforeend', html);
  293. }
  294. const prefix = rowPrefix();
  295. const el = document.getElementById(`${prefix}-${data.id}`);
  296. if (el) {
  297. requestAnimationFrame(() => requestAnimationFrame(() => {
  298. el.classList.remove('crud-row--new');
  299. }));
  300. }
  301. }
  302. function buildRowHTML(data) {
  303. const prefix = rowPrefix();
  304. let metaHtml = '';
  305. let editFields = '';
  306. // Kunden
  307. if (data.projectCount !== undefined) {
  308. const c = data.projectCount;
  309. metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? 'Projekt' : 'Projekte'}</span>`;
  310. editFields = `
  311. <label class="entry-form__label">Name</label>
  312. <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
  313. <label class="entry-form__label">Stundensatz</label>
  314. <div class="entry-form__field" style="gap:8px">
  315. <input type="number" class="input edit-rate" style="width:100px" value="${data.hourlyRate ?? ''}" step="0.01" min="0" />
  316. <span style="color:#7a8a9a;font-size:0.875rem">€</span>
  317. </div>
  318. <label class="entry-form__label">Bemerkung</label>
  319. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
  320. }
  321. // Projekte
  322. if (data.clientName !== undefined && data.projectCount === undefined) {
  323. metaHtml = `<span class="crud-row__meta">${data.clientName}</span>`;
  324. editFields = `
  325. <label class="entry-form__label">Name</label>
  326. <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
  327. <label class="entry-form__label">Kunde</label>
  328. <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
  329. <label class="entry-form__label">Bemerkung</label>
  330. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
  331. }
  332. // Leistungen
  333. if (data.billable !== undefined) {
  334. editFields = `
  335. <label class="entry-form__label">Name</label>
  336. <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
  337. <label class="entry-form__label">Verrechenbar</label>
  338. <div class="entry-form__field">
  339. <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
  340. <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
  341. <span style="font-size:0.875rem">Ja, diese Leistung ist verrechenbar</span>
  342. </label>
  343. </div>
  344. <label class="entry-form__label">Bemerkung</label>
  345. <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
  346. }
  347. return `
  348. <div class="crud-row crud-row--new"
  349. id="${prefix}-${data.id}"
  350. data-id="${data.id}"
  351. data-archived="0"
  352. data-name="${data.name}"
  353. ${data.hourlyRate !== undefined ? `data-rate="${data.hourlyRate ?? ''}"` : ''}
  354. ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
  355. ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
  356. data-note="${data.note ?? ''}">
  357. <div class="crud-row__display">
  358. <div class="crud-row__info">
  359. <span class="crud-row__name">${data.name}</span>
  360. ${metaHtml}
  361. </div>
  362. <div class="crud-row__actions">
  363. <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
  364. <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>
  365. </button>
  366. <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
  367. <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>
  368. </button>
  369. </div>
  370. </div>
  371. <div class="crud-row__edit" hidden>
  372. <div class="entry-form__grid entry-form__grid--inline">
  373. ${editFields}
  374. <div class="entry-form__actions">
  375. <button type="button" class="btn btn-primary" data-action="save">Sichern</button>
  376. <button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button>
  377. </div>
  378. </div>
  379. </div>
  380. </div>`;
  381. }
  382. // ── Init ──────────────────────────────────────────────────────────────────────
  383. document.addEventListener('DOMContentLoaded', () => {
  384. initCreateForm();
  385. initList();
  386. initTabs();
  387. });