|
- // assets/scripts/crud.js
-
- import { esc, createTranslator, ANIMATION_MS, removeWithAnimation, animateIn } from './utils.js';
-
- const api = window.CRUD?.apiBase ?? '';
-
- const t = createTranslator('CRUD');
-
- // ── Hilfsfunktionen ──────────────────────────────────────────────────────────
-
- function buildClientOptions(selectedId = null) {
- const clients = window.CRUD?.clients ?? [];
- let html = `<option value="">${t('selectPh')}</option>`;
- clients.forEach(c => {
- const sel = String(c.id) === String(selectedId) ? ' selected' : '';
- html += `<option value="${c.id}"${sel}>${esc(c.name)}</option>`;
- });
- return html;
- }
-
- function rowPrefix() {
- 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() {
- ['create-name', 'create-note'].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(t('errorNoName')); return; }
-
- const btn = document.getElementById('btn-create-save');
- const body = buildCreateBody();
-
- if (btn) btn.disabled = true;
- 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 ?? t('errorSave'));
- return;
- }
-
- const data = await res.json();
- appendRowToList(data);
- document.getElementById('crud-create')?.classList.remove('crud-create--visible');
- resetCreateForm();
- } catch {
- alert(t('errorSave'));
- } finally {
- if (btn) btn.disabled = false;
- }
- }
-
- function buildCreateBody() {
- const body = {
- name: document.getElementById('create-name')?.value?.trim(),
- note: document.getElementById('create-note')?.value || null,
- };
-
- const rate = document.getElementById('create-rate');
- if (rate) body.hourlyRate = rate.value || null;
-
- const client = document.getElementById('create-client');
- if (client) body.clientId = parseInt(client.value, 10) || null;
-
- 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 row = e.target.closest('.crud-row');
- if (!row) return;
-
- switch (actionEl.dataset.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 saveBtn = row.querySelector('[data-action="save"]');
- if (saveBtn?.disabled) return;
-
- const name = row.querySelector('.edit-name')?.value?.trim();
- if (!name) { alert(t('errorNoName')); return; }
-
- const body = buildEditBody(row);
-
- if (saveBtn) saveBtn.disabled = true;
- try {
- const res = await fetch(`${api}/${row.dataset.id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
-
- if (!res.ok) { alert(t('errorSave')); return; }
-
- const data = await res.json();
- updateRowDisplay(row, data);
- closeEdit(row);
- } catch {
- alert(t('errorSave'));
- } finally {
- if (saveBtn) saveBtn.disabled = false;
- }
- }
-
- function buildEditBody(row) {
- const body = {
- name: row.querySelector('.edit-name')?.value?.trim(),
- note: row.querySelector('.edit-note')?.value || null,
- };
-
- const rate = row.querySelector('.edit-rate');
- if (rate) body.hourlyRate = rate.value || null;
-
- const client = row.querySelector('.edit-client');
- if (client) body.clientId = parseInt(client.value, 10) || null;
-
- 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;
- if (data.clientName && metaEl) metaEl.textContent = data.clientName;
-
- 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 ?? '';
-
- 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(t('confirmDelete'))) return;
-
- try {
- const res = await fetch(`${api}/${row.dataset.id}`, { method: 'DELETE' });
-
- if (res.status === 409) {
- if (confirm(t('confirmArchive'))) {
- await archiveRow(row);
- }
- return;
- }
-
- if (!res.ok) { alert(t('errorDelete')); return; }
-
- removeWithAnimation(row, 'crud-row--removing');
- } catch {
- alert(t('errorDelete'));
- }
- }
-
- async function archiveRow(row) {
- try {
- const res = await fetch(`${api}/${row.dataset.id}/archive`, { method: 'PATCH' });
- if (!res.ok) { alert(t('errorArchive')); 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(t('errorArchive'));
- }
- }
-
- async function unarchiveRow(row) {
- try {
- const res = await fetch(`${api}/${row.dataset.id}/unarchive`, { method: 'PATCH' });
- if (!res.ok) { alert(t('errorRestore')); 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(t('errorRestore'));
- }
- }
-
- 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="${t('btnRestore')}">
- <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="${t('btnEdit')}">
- <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="${t('btnDelete')}">
- <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);
-
- if (data.billable !== undefined) {
- const groupLabel = data.billable ? t('groupBillable') : t('groupNotBillable');
- 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 {
- const groupHtml = `<div class="crud-list__group"><div class="crud-list__group-label">${esc(groupLabel)}</div>${html}</div>`;
- if (!data.billable) {
- list.insertAdjacentHTML('beforeend', groupHtml);
- } else {
- 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) animateIn(el, 'crud-row--new');
- }
-
- function buildRowHTML(data) {
- const prefix = rowPrefix();
- let metaHtml = '';
- let editFields = '';
-
- if (data.projectCount !== undefined) {
- const c = data.projectCount;
- metaHtml = `<span class="crud-row__meta">${c} ${c === 1 ? t('projectSingular') : t('projectPlural')}</span>`;
- editFields = `
- <label class="entry-form__label">${t('labelName')}</label>
- <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
- <label class="entry-form__label">${t('labelRate')}</label>
- <div class="entry-form__field" style="gap:8px">
- <input type="number" class="input edit-rate" style="width:100px" value="${esc(data.hourlyRate ?? '')}" step="0.01" min="0" />
- <span style="color:#7a8a9a;font-size:0.875rem">€</span>
- </div>
- <label class="entry-form__label">${t('labelNote')}</label>
- <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
- }
-
- if (data.clientName !== undefined && data.projectCount === undefined) {
- metaHtml = `<span class="crud-row__meta">${esc(data.clientName)}</span>`;
- editFields = `
- <label class="entry-form__label">${t('labelName')}</label>
- <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
- <label class="entry-form__label">${t('labelClient')}</label>
- <div class="entry-form__field"><select class="select edit-client">${buildClientOptions(data.clientId)}</select></div>
- <label class="entry-form__label">${t('labelNote')}</label>
- <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(data.note ?? '')}</textarea></div>`;
- }
-
- if (data.billable !== undefined) {
- editFields = `
- <label class="entry-form__label">${t('labelName')}</label>
- <div class="entry-form__field"><input type="text" class="input edit-name" value="${esc(data.name)}" /></div>
- <label class="entry-form__label">${t('labelBillable')}</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">${t('billableLabel')}</span>
- </label>
- </div>
- <label class="entry-form__label">${t('labelNote')}</label>
- <div class="entry-form__field"><textarea class="textarea edit-note" rows="2">${esc(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="${esc(data.name)}"
- ${data.hourlyRate !== undefined ? `data-rate="${esc(data.hourlyRate ?? '')}"` : ''}
- ${data.clientId !== undefined ? `data-client-id="${data.clientId}"` : ''}
- ${data.billable !== undefined ? `data-billable="${data.billable ? '1' : '0'}"` : ''}
- data-note="${esc(data.note ?? '')}">
-
- <div class="crud-row__display">
- <div class="crud-row__info">
- <span class="crud-row__name">${esc(data.name)}</span>
- ${metaHtml}
- </div>
- <div class="crud-row__actions">
- <button class="crud-row__btn crud-row__btn--edit" data-action="edit" title="${t('btnEdit')}">
- <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="${t('btnDelete')}">
- <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">${t('btnSave')}</button>
- <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
- </div>
- </div>
- </div>
- </div>`;
- }
-
- // ── Init ─────────────────────────────────────────────────────────────────────
-
- document.addEventListener('DOMContentLoaded', () => {
- initCreateForm();
- initList();
- initTabs();
- });
|