|
- // assets/scripts/crud.js
- // Generisches CRUD-Handler für Kunden, Projekte, Leistungen
-
- const api = window.CRUD?.apiBase ?? '';
-
- // ── Hilfsfunktionen ───────────────────────────────────────────────────────────
-
- function buildClientOptions(selectedId = null) {
- const clients = window.CRUD?.clients ?? [];
- let html = '<option value="">Bitte wählen</option>';
- clients.forEach(c => {
- const sel = String(c.id) === String(selectedId) ? ' selected' : '';
- html += `<option value="${c.id}"${sel}>${c.name}</option>`;
- });
- return html;
- }
-
- function rowPrefix() {
- // Ermittelt den Entitätstyp aus der URL
- if (location.pathname.includes('/clients')) return 'client';
- if (location.pathname.includes('/projects')) return 'project';
- if (location.pathname.includes('/services')) return 'service';
- return 'row';
- }
-
- // ── Create-Formular ───────────────────────────────────────────────────────────
-
- function initCreateForm() {
- const btnNew = document.getElementById('btn-new');
- const form = document.getElementById('crud-create');
- const btnSave = document.getElementById('btn-create-save');
- const btnCancel = document.getElementById('btn-create-cancel');
-
- if (!btnNew || !form) return;
-
- btnNew.addEventListener('click', () => {
- form.classList.toggle('crud-create--visible');
- document.getElementById('create-name')?.focus();
- });
-
- btnCancel?.addEventListener('click', () => {
- form.classList.remove('crud-create--visible');
- resetCreateForm();
- });
-
- btnSave?.addEventListener('click', () => createEntity());
- }
-
- function resetCreateForm() {
- const fields = ['create-name', 'create-note'];
- fields.forEach(id => {
- const el = document.getElementById(id);
- if (el) el.value = '';
- });
- const rate = document.getElementById('create-rate');
- if (rate) rate.value = '';
- const billable = document.getElementById('create-billable');
- if (billable) billable.checked = true;
- const client = document.getElementById('create-client');
- if (client) client.value = '';
- }
-
- async function createEntity() {
- const name = document.getElementById('create-name')?.value?.trim();
- if (!name) { alert('Bitte einen Namen eingeben.'); return; }
-
- const body = buildCreateBody();
-
- try {
- const res = await fetch(api, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- alert(err.error ?? 'Fehler beim Speichern.');
- return;
- }
-
- const data = await res.json();
- appendRowToList(data);
- document.getElementById('crud-create')?.classList.remove('crud-create--visible');
- resetCreateForm();
-
- } catch (err) {
- console.error(err);
- alert('Fehler beim Speichern.');
- }
- }
-
- function buildCreateBody() {
- const body = {
- name: document.getElementById('create-name')?.value?.trim(),
- note: document.getElementById('create-note')?.value || null,
- };
-
- // Kunden-spezifisch
- const rate = document.getElementById('create-rate');
- if (rate) body.hourlyRate = rate.value || null;
-
- // Projekt-spezifisch
- const client = document.getElementById('create-client');
- if (client) body.clientId = parseInt(client.value) || null;
-
- // Leistungs-spezifisch
- const billable = document.getElementById('create-billable');
- if (billable) body.billable = billable.checked;
-
- return body;
- }
-
- // ── Liste: Event Delegation ────────────────────────────────────────────────────
-
- function initList() {
- const list = document.getElementById('crud-list');
- if (!list) return;
-
- list.addEventListener('click', e => {
- const actionEl = e.target.closest('[data-action]');
- if (!actionEl) return;
-
- const action = actionEl.dataset.action;
- const row = e.target.closest('.crud-row');
- if (!row) return;
-
- switch (action) {
- case 'edit': openEdit(row); break;
- case 'delete': deleteRow(row); break;
- case 'save': saveEdit(row); break;
- case 'cancel': closeEdit(row); break;
- case 'unarchive': unarchiveRow(row); break;
- }
- });
- }
-
- // ── Inline Edit ───────────────────────────────────────────────────────────────
-
- function openEdit(row) {
- row.querySelector('.crud-row__display').hidden = true;
- row.querySelector('.crud-row__edit').hidden = false;
- row.querySelector('.edit-name')?.focus();
- }
-
- function closeEdit(row) {
- row.querySelector('.crud-row__display').hidden = false;
- row.querySelector('.crud-row__edit').hidden = true;
- }
-
- async function saveEdit(row) {
- const id = row.dataset.id;
- const name = row.querySelector('.edit-name')?.value?.trim();
-
- if (!name) { alert('Bitte einen Namen eingeben.'); return; }
-
- const body = buildEditBody(row);
-
- try {
- const res = await fetch(`${api}/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
-
- if (!res.ok) { alert('Fehler beim Speichern.'); return; }
-
- const data = await res.json();
- updateRowDisplay(row, data);
- closeEdit(row);
-
- } catch (err) {
- console.error(err);
- alert('Fehler beim Speichern.');
- }
- }
-
- function buildEditBody(row) {
- const body = {
- name: row.querySelector('.edit-name')?.value?.trim(),
- note: row.querySelector('.edit-note')?.value || null,
- };
-
- // Kunden
- const rate = row.querySelector('.edit-rate');
- if (rate) body.hourlyRate = rate.value || null;
-
- // Projekt
- const client = row.querySelector('.edit-client');
- if (client) body.clientId = parseInt(client.value) || null;
-
- // Leistung
- const billable = row.querySelector('.edit-billable');
- if (billable) body.billable = billable.checked;
-
- return body;
- }
-
- function updateRowDisplay(row, data) {
- const nameEl = row.querySelector('.crud-row__name');
- const metaEl = row.querySelector('.crud-row__meta');
-
- if (nameEl) nameEl.textContent = data.name;
-
- // Kunden: Meta-Text unverändert (Projektanzahl ändert sich nicht)
- // Projekte: Client-Name aktualisieren
- if (data.clientName && metaEl) metaEl.textContent = data.clientName;
-
- // data-Attribute aktualisieren
- row.dataset.name = data.name;
- if (data.hourlyRate !== undefined) row.dataset.rate = data.hourlyRate ?? '';
- if (data.clientId !== undefined) row.dataset.clientId = data.clientId;
- if (data.billable !== undefined) row.dataset.billable = data.billable ? '1' : '0';
- if (data.note !== undefined) row.dataset.note = data.note ?? '';
-
- // Edit-Felder aktualisieren
- const editName = row.querySelector('.edit-name');
- if (editName) editName.value = data.name;
-
- const editNote = row.querySelector('.edit-note');
- if (editNote) editNote.value = data.note ?? '';
-
- const editRate = row.querySelector('.edit-rate');
- if (editRate) editRate.value = data.hourlyRate ?? '';
-
- const editBillable = row.querySelector('.edit-billable');
- if (editBillable) editBillable.checked = !!data.billable;
- }
-
- // ── Delete ────────────────────────────────────────────────────────────────────
-
- async function deleteRow(row) {
- if (!confirm('Wirklich löschen?')) return;
-
- try {
- const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });
-
- if (res.status === 409) {
- if (confirm('Dieser Eintrag hat abhängige Zeiteinträge und kann nicht gelöscht werden.\nStattdessen archivieren?')) {
- await archiveRow(row);
- }
- return;
- }
-
- if (!res.ok) { alert('Fehler beim Löschen.'); return; }
-
- row.classList.add('crud-row--removing');
- setTimeout(() => row.remove(), 280);
-
- } catch {
- alert('Fehler beim Löschen.');
- }
- }
-
- async function archiveRow(row) {
- try {
- const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
- if (!res.ok) { alert('Fehler beim Archivieren.'); return; }
-
- row.dataset.archived = '1';
- row.classList.add('crud-row--archived');
- updateRowArchivedState(row, true);
- filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
-
- } catch {
- alert('Fehler beim Archivieren.');
- }
- }
-
- async function unarchiveRow(row) {
- try {
- const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
- if (!res.ok) { alert('Fehler beim Wiederherstellen.'); return; }
-
- row.dataset.archived = '0';
- row.classList.remove('crud-row--archived');
- updateRowArchivedState(row, false);
- filterByTab(document.querySelector('.crud-tab--active')?.dataset.tab ?? 'active');
-
- } catch {
- alert('Fehler beim Wiederherstellen.');
- }
- }
-
- function updateRowArchivedState(row, archived) {
- const actions = row.querySelector('.crud-row__actions');
- if (!actions) return;
-
- if (archived) {
- actions.innerHTML = `
- <button class="crud-row__btn crud-row__btn--restore" data-action="unarchive" title="Wiederherstellen">
- <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>
- </button>`;
- row.querySelector('.crud-row__edit')?.remove();
- } else {
- actions.innerHTML = `
- <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
- <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>
- </button>
- <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
- <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>
- </button>`;
- }
- }
-
- function filterByTab(tab) {
- document.querySelectorAll('#crud-list .crud-row').forEach(row => {
- row.hidden = tab === 'active'
- ? row.dataset.archived === '1'
- : row.dataset.archived === '0';
- });
- }
-
- function initTabs() {
- const tabs = document.querySelectorAll('.crud-tab');
- if (!tabs.length) return;
-
- filterByTab('active');
-
- tabs.forEach(tab => {
- tab.addEventListener('click', () => {
- tabs.forEach(t => t.classList.remove('crud-tab--active'));
- tab.classList.add('crud-tab--active');
- filterByTab(tab.dataset.tab);
-
- const btnNew = document.getElementById('btn-new');
- if (btnNew) btnNew.hidden = tab.dataset.tab === 'archived';
- });
- });
- }
-
- // ── Neue Zeile einfügen ───────────────────────────────────────────────────────
-
- function appendRowToList(data) {
- const list = document.getElementById('crud-list');
- if (!list) return;
-
- const html = buildRowHTML(data);
-
- // Services haben Gruppen → in die richtige Gruppe einfügen
- if (data.billable !== undefined) {
- const groupLabel = data.billable ? 'Verrechenbar' : 'Nicht-verrechenbar';
- let targetGroup = null;
-
- list.querySelectorAll('.crud-list__group').forEach(g => {
- if (g.querySelector('.crud-list__group-label')?.textContent === groupLabel) {
- targetGroup = g;
- }
- });
-
- if (targetGroup) {
- targetGroup.insertAdjacentHTML('beforeend', html);
- } else {
- // Gruppe existiert noch nicht → neu anlegen
- const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${groupLabel}</div>${html}</div>`;
- if (!data.billable) {
- // Nicht-verrechenbar immer ans Ende
- list.insertAdjacentHTML('beforeend', groupHtml);
- } else {
- // Verrechenbar vor die erste existierende Gruppe
- const firstGroup = list.querySelector('.crud-list__group');
- firstGroup
- ? firstGroup.insertAdjacentHTML('beforebegin', groupHtml)
- : list.insertAdjacentHTML('beforeend', groupHtml);
- }
- }
- } else {
- list.insertAdjacentHTML('beforeend', html);
- }
-
- const prefix = rowPrefix();
- const el = document.getElementById(`${prefix}-${data.id}`);
- if (el) {
- requestAnimationFrame(() => requestAnimationFrame(() => {
- el.classList.remove('crud-row--new');
- }));
- }
- }
-
- function buildRowHTML(data) {
- const prefix = rowPrefix();
- let metaHtml = '';
- let editFields = '';
-
- // Kunden
- if (data.projectCount !== undefined) {
- const c = data.projectCount;
- metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? 'Projekt' : 'Projekte'}</span>`;
- editFields = `
- <label class="entry-form__label">Name</label>
- <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
- <label class="entry-form__label">Stundensatz</label>
- <div class="entry-form__field" style="gap:8px">
- <input type="number" class="input edit-rate" style="width:100px" value="${data.hourlyRate ?? ''}" step="0.01" min="0" />
- <span style="color:#7a8a9a;font-size:0.875rem">€</span>
- </div>
- <label class="entry-form__label">Bemerkung</label>
- <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
- }
-
- // Projekte
- if (data.clientName !== undefined && data.projectCount === undefined) {
- metaHtml = `<span class="crud-row__meta">${data.clientName}</span>`;
- editFields = `
- <label class="entry-form__label">Name</label>
- <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
- <label class="entry-form__label">Kunde</label>
- <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
- <label class="entry-form__label">Bemerkung</label>
- <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
- }
-
- // Leistungen
- if (data.billable !== undefined) {
- editFields = `
- <label class="entry-form__label">Name</label>
- <div class="entry-form__field"><input type="text" class="input edit-name" value="${data.name}" /></div>
- <label class="entry-form__label">Verrechenbar</label>
- <div class="entry-form__field">
- <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
- <input type="checkbox" class="edit-billable" ${data.billable ? 'checked' : ''} />
- <span style="font-size:0.875rem">Ja, diese Leistung ist verrechenbar</span>
- </label>
- </div>
- <label class="entry-form__label">Bemerkung</label>
- <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${data.note ?? ''}</textarea></div>`;
- }
-
- return `
- <div class="crud-row crud-row--new"
- id="${prefix}-${data.id}"
- data-id="${data.id}"
- data-archived="0"
- data-name="${data.name}"
- ${data.hourlyRate !== undefined ? `data-rate="${data.hourlyRate ?? ''}"` : ''}
- ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
- ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
- data-note="${data.note ?? ''}">
-
- <div class="crud-row__display">
- <div class="crud-row__info">
- <span class="crud-row__name">${data.name}</span>
- ${metaHtml}
- </div>
- <div class="crud-row__actions">
- <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="Bearbeiten">
- <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>
- </button>
- <button class="crud-row__btn crud-row__btn--delete" data-action="delete" title="Löschen">
- <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>
- </button>
- </div>
- </div>
-
- <div class="crud-row__edit" hidden>
- <div class="entry-form__grid entry-form__grid--inline">
- ${editFields}
- <div class="entry-form__actions">
- <button type="button" class="btn btn-primary" data-action="save">Sichern</button>
- <button type="button" class="btn btn-secondary" data-action="cancel">Abbrechen</button>
- </div>
- </div>
- </div>
- </div>`;
- }
-
- // ── Init ──────────────────────────────────────────────────────────────────────
-
- document.addEventListener('DOMContentLoaded', () => {
- initCreateForm();
- initList();
- initTabs();
- });
|