diff --git a/modules/market_management/service.php b/modules/market_management/service.php index 23cc621..9ee5746 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -953,6 +953,49 @@ function market_management_set_occurrence_archive(PDO $pdo, int $occurrenceId, b function market_management_delete_day(PDO $pdo, int $dayId): array { + $dayStmt = $pdo->prepare( + 'SELECT id, occurrence_id, date + FROM market_occurrence_day + WHERE id = :id' + ); + $dayStmt->execute([':id' => $dayId]); + $day = $dayStmt->fetch(); + if (!is_array($day)) { + throw new RuntimeException('Durchführungstag nicht gefunden.'); + } + + $countStmt = $pdo->prepare( + 'SELECT COUNT(*) + FROM market_occurrence_day + WHERE occurrence_id = :occurrence_id' + ); + $countStmt->execute([':occurrence_id' => (int) $day['occurrence_id']]); + if ((int) $countStmt->fetchColumn() <= 1) { + throw new InvalidArgumentException('Die Durchführung muss mindestens einen Durchführungstag behalten.'); + } + + $firstDayStmt = $pdo->prepare( + 'SELECT id + FROM market_occurrence_day + WHERE occurrence_id = :occurrence_id + ORDER BY date ASC, id ASC + LIMIT 1' + ); + $firstDayStmt->execute([':occurrence_id' => (int) $day['occurrence_id']]); + if ((int) $firstDayStmt->fetchColumn() === $dayId) { + throw new InvalidArgumentException('Der erste Durchführungstag kann nicht gelöscht werden.'); + } + + $salesStmt = $pdo->prepare( + 'SELECT COUNT(*) + FROM sales_order + WHERE market_occurrence_day_id = :day_id' + ); + $salesStmt->execute([':day_id' => $dayId]); + if ((int) $salesStmt->fetchColumn() > 0) { + throw new InvalidArgumentException('Ein Durchführungstag mit Verkäufen kann nicht gelöscht werden.'); + } + $stmt = $pdo->prepare('DELETE FROM market_occurrence_day WHERE id = :id RETURNING id, occurrence_id, date'); $stmt->execute([':id' => $dayId]); $day = $stmt->fetch(); diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index eac97dd..0e8240d 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -1116,7 +1116,9 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM echo " const applyMarketTabs = (root) => { const group = root.querySelector('[data-pattern=\"card-gruppe-mit-tastennavigation\"] [role=\"tablist\"]'); if (group) { group.dataset.component = 'tab-navigation'; group.dataset.componentSize = 'large'; group.querySelectorAll('[data-market-day-tab]').forEach((tab) => { tab.dataset.componentPart = 'tab-button'; tab.classList.remove('sg-button--active'); }); } root.querySelectorAll('[data-market-day-tab]').forEach((tab) => { if (tab.dataset.marketTabBound === 'true') { return; } tab.dataset.marketTabBound = 'true'; tab.addEventListener('click', () => { const tabGroup = tab.closest('[data-pattern=\"card-gruppe-mit-tastennavigation\"]'); tabGroup?.querySelectorAll('[data-market-day-tab]').forEach((candidate) => candidate.setAttribute('aria-selected', String(candidate === tab))); tabGroup?.querySelectorAll('[role=\"tabpanel\"]').forEach((panel) => { panel.hidden = panel.id !== tab.getAttribute('aria-controls'); }); tab.focus(); }); }); };"; echo " const syncMarketDayAddButton = (root = contentRoot) => { if (!root.querySelector('[data-market-inherit-row]')) { enhanceMarketDayInheritance(root); } const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; const days = Array.isArray(payload?.occurrence?.days) ? payload.occurrence.days.filter((day) => !day.__draft).slice().sort((left, right) => String(left.date).localeCompare(String(right.date))) : []; const nextDate = (date) => { const value = new Date(String(date) + 'T00:00:00Z'); value.setUTCDate(value.getUTCDate() + 1); return value.toISOString().slice(0, 10); }; root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=\"day\"]').forEach((form) => { const input = form.querySelector('[name=\"date\"]'); if (!input) { return; } const index = days.findIndex((day) => String(day.id) === String(form.dataset.marketEditId)); const minimum = form.dataset.marketEditDraft === 'true' ? days.at(-1)?.date : index > 0 ? days[index - 1].date : ''; input.min = minimum ? nextDate(minimum) : ''; }); const addButton = root.querySelector('[data-market-day-add]'); if (!addButton) { return; } const editing = Array.from(root.querySelectorAll('[data-market-edit-form]')).some((form) => form.closest('[data-shared-editable-card-form]')?.hidden === false); addButton.disabled = editing; addButton.setAttribute('aria-disabled', String(editing)); addButton.classList.toggle('sg-button--active', !editing); addButton.classList.toggle('sg-button--inactive', editing); };"; echo " const enhanceMarketDayInheritance = (root) => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; const days = Array.isArray(payload?.occurrence?.days) ? payload.occurrence.days.filter((day) => !day.__draft).slice().sort((left, right) => String(left.date).localeCompare(String(right.date))) : []; const firstDayId = days[0]?.id; root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=\"day\"]').forEach((form) => { const dayId = form.dataset.marketEditId; if (!dayId || String(dayId) === String(firstDayId)) { return; } const dateGroup = form.querySelector('[name=\"date\"]')?.closest('.sg-form-sections-card__field-group'); const observationsGroup = form.querySelector('[name=\"observations\"]')?.closest('.sg-form-sections-card__field-group'); if (!dateGroup || !observationsGroup) { return; } let row = form.querySelector('[data-market-inherit-row]'); if (!row) { row = document.createElement('div'); row.className = 'sg-form-sections-card__field-group'; row.dataset.marketInheritRow = 'true'; row.innerHTML = ''; observationsGroup.after(row); row.querySelector('[data-market-inherit-toggle]').addEventListener('click', (event) => { event.stopImmediatePropagation(); const toggle = row.querySelector('[data-market-inherit-toggle]'); const checked = toggle.getAttribute('aria-checked') !== 'true'; toggle.setAttribute('aria-checked', String(checked)); toggle.classList.toggle('sg-form-active', checked); row.querySelector('[name=\"inherit_from_first_day\"]').value = checked ? '1' : '0'; form.querySelectorAll('input, select, textarea').forEach((control) => { if (control.name !== 'date' && control.name !== 'inherit_from_first_day' && control.name !== 'observations') { control.disabled = checked; control.setAttribute('aria-disabled', String(checked)); control.classList.toggle('sg-form-disabled', checked); control.classList.toggle('sg-input-single-line--disabled', checked && control.tagName === 'INPUT'); } }); syncMarketDayAddButton(root); }); } const day = days.find((candidate) => String(candidate.id) === String(dayId)); const inherited = Boolean(day?.inherit_from_first_day); const toggle = row.querySelector('[data-market-inherit-toggle]'); toggle.setAttribute('aria-checked', String(inherited)); toggle.classList.toggle('sg-form-active', inherited); row.querySelector('[name=\"inherit_from_first_day\"]').value = inherited ? '1' : '0'; form.querySelectorAll('input, select, textarea').forEach((control) => { if (control.name !== 'date' && control.name !== 'inherit_from_first_day' && control.name !== 'observations') { control.disabled = inherited; control.setAttribute('aria-disabled', String(inherited)); control.classList.toggle('sg-form-disabled', inherited); control.classList.toggle('sg-input-single-line--disabled', inherited && control.tagName === 'INPUT'); } }); }); };"; - echo " const rerenderMarketDetail = (payload) => { const drawer = getMarketDrawer(); const content = drawer?.querySelector('[data-market-drawer-content]'); if (!drawer || !content) { return; } drawer.dataset.marketPayload = JSON.stringify(payload); content.innerHTML = renderMarketDetailWithActions(payload); applyMarketTabs(content); syncMarketDayAddButton(content); };"; + echo " const marketDayDeleteConfirmation = (id, label) => '';"; + echo " const enhanceMarketDayDeleteControls = (root) => { const cards = Array.from(root.querySelectorAll('.market-days-group .sg-content-cards-group > [data-shared-editable-action-card]')); cards.forEach((card, index) => { if (index === 0 || card.querySelector('[data-market-edit-form][data-market-edit-draft=\"true\"]')) { return; } const form = card.querySelector('[data-market-edit-form][data-market-edit-scope=\"day\"]'); const actions = card.querySelector('.sg-object-card__header .sg-component-row'); if (!form || !actions || actions.querySelector('[data-market-day-delete-button]')) { return; } const dayId = form.dataset.marketEditId || ''; const label = card.querySelector('.sg-object-card__header .sg-strong')?.textContent?.trim() || 'diesen Durchführungstag'; if (!dayId) { return; } card.dataset.marketDayDeleteId = dayId; card.dataset.sharedEditableDeleteToken = 'DELETE'; actions.insertAdjacentHTML('afterbegin', ''); card.insertAdjacentHTML('beforeend', marketDayDeleteConfirmation(dayId, label)); }); };"; + echo " const rerenderMarketDetail = (payload) => { const drawer = getMarketDrawer(); const content = drawer?.querySelector('[data-market-drawer-content]'); if (!drawer || !content) { return; } drawer.dataset.marketPayload = JSON.stringify(payload); content.innerHTML = renderMarketDetailWithActions(payload); applyMarketTabs(content); enhanceMarketDayDeleteControls(content); syncMarketDayAddButton(content); };"; echo " const addMarketDayDraft = () => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; if (!payload?.occurrence) { return; } const days = Array.isArray(payload.occurrence.days) ? payload.occurrence.days : []; if (days.some((day) => day.__draft)) { return; } days.push({ id: 'draft-' + Date.now(), date: '', __draft: true }); payload.occurrence.days = days; rerenderMarketDetail(payload); const draftEdit = contentRoot.querySelector('[data-market-edit-form][data-market-edit-draft=\"true\"]')?.closest('[data-shared-editable-action-card]')?.querySelector('[data-shared-editable-card-edit]'); draftEdit?.click(); window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); };"; echo " const discardMarketDayDraft = () => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; if (!payload?.occurrence || !Array.isArray(payload.occurrence.days)) { return; } const days = payload.occurrence.days.filter((day) => !day.__draft); if (days.length === payload.occurrence.days.length) { return; } payload.occurrence.days = days; rerenderMarketDetail(payload); };"; echo " const closeMarketDrawer = () => { setMarketDrawerState(false); contentRoot.querySelectorAll('[data-market-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', 'false')); };"; @@ -1127,10 +1129,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM echo " const restoreMarketList = () => { if (marketMobileDetailHost?.parentNode) { const drawer = marketMobileDetailHost.querySelector('[data-market-mobile-drawer]'); if (drawer) { contentRoot.appendChild(drawer); drawer.removeAttribute('data-market-mobile-drawer'); drawer.dataset.open = 'false'; drawer.setAttribute('aria-hidden', 'true'); } marketMobileDetailHost.parentNode.removeChild(marketMobileDetailHost); } marketMobileDetailHost = null; marketDetailOpen = false; document.body.classList.remove('sg-market-mobile-detail-open'); bindTable(); };"; echo " const openMarketMobileDetail = (trigger) => { restoreMarketList(); const sourceDrawer = contentRoot.querySelector('[data-market-drawer]'); if (!sourceDrawer) { return; } const host = document.createElement('div'); host.className = 'sg-market-mobile-detail-host'; host.dataset.marketMobileDetailHost = 'true'; host.innerHTML = '
'; sourceDrawer.dataset.marketMobileDrawer = 'true'; sourceDrawer.dataset.open = 'true'; sourceDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); document.body.appendChild(host); marketMobileDetailHost = host; document.body.classList.add('sg-market-mobile-detail-open'); marketOpeningMobileDetail = true; void openMarketDrawer(trigger).finally(() => { marketOpeningMobileDetail = false; }); marketDetailOpen = true; window.history.pushState({ marketView: 'detail' }, '', window.location.href); };"; echo " const deleteMarketOccurrence = async (submit) => { if (submit.disabled) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); const status = card?.querySelector('[data-market-delete-status]'); const occurrenceId = card?.dataset.marketDeleteOccurrenceId || ''; if (!card || !input || input.value !== 'DELETE' || !occurrenceId) { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete', id: occurrenceId, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } closeMarketDrawer(); if (marketDetailOpen) { restoreMarketList(); } await loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() }); } catch (error) { if (status) { status.textContent = marketEscape(error.message); } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };"; + echo " const deleteMarketDay = async (submit) => { if (submit.disabled) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); const status = card?.querySelector('[data-market-day-delete-status]'); const dayId = card?.dataset.marketDayDeleteId || ''; if (!card || !input || input.value !== 'DELETE' || !dayId) { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete_day', id: dayId, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };"; + echo " const marketDayDeleteObserver = new MutationObserver(() => enhanceMarketDayDeleteControls(contentRoot)); marketDayDeleteObserver.observe(contentRoot, { childList: true, subtree: true });"; echo " if (!marketsBindingsInstalled) {"; echo " marketsBindingsInstalled = true;"; echo " document.addEventListener('click', (event) => { const dayAdd = event.target.closest('[data-market-day-add]'); if (dayAdd) { event.preventDefault(); addMarketDayDraft(); return; } const editButton = event.target.closest('[data-shared-editable-card-edit]'); if (editButton) { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); return; } const cancelButton = event.target.closest('[data-shared-editable-card-cancel]'); if (cancelButton) { if (cancelButton.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]')) { window.setTimeout(discardMarketDayDraft, 0); } else { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); } } });"; - echo " document.addEventListener('click', (event) => { const createButton = event.target.closest('[data-market-occurrence-create]'); if (createButton) { event.preventDefault(); void openNewMarketDrawer(); return; } if (event.target.closest('[data-market-create-cancel]')) { closeMarketDrawer(); } });"; + echo " document.addEventListener('click', (event) => { const deleteDaySubmit = event.target.closest('[data-market-day-delete-submit]'); if (deleteDaySubmit) { void deleteMarketDay(deleteDaySubmit); return; } const createButton = event.target.closest('[data-market-occurrence-create]'); if (createButton) { event.preventDefault(); void openNewMarketDrawer(); return; } if (event.target.closest('[data-market-create-cancel]')) { closeMarketDrawer(); } });"; echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-create-form]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-create-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'create', fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const detailResponse = await fetch('/api/market-occurrence.php?id=' + encodeURIComponent(result.occurrence_id), { credentials: 'same-origin' }); const payload = await detailResponse.json(); if (!detailResponse.ok || !payload.ok) { throw new Error(payload.error || 'Die neue Durchführung konnte nicht geladen werden.'); } const drawer = getMarketDrawer(); const title = drawer?.querySelector('[data-market-drawer-title]'); const content = drawer?.querySelector('[data-market-drawer-content]'); if (drawer) { drawer.dataset.marketPayload = JSON.stringify(payload); } if (title) { title.textContent = marketDrawerTitle(payload); } if (content) { content.innerHTML = renderMarketDetailWithActions(payload); applyMarketTabs(content); syncMarketDayAddButton(content); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });"; echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-edit-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'create_day', id: form.dataset.marketOccurrenceId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });"; echo " document.addEventListener('click', (event) => { const deleteSubmit = event.target.closest('[data-market-delete-submit]'); if (deleteSubmit) { void deleteMarketOccurrence(deleteSubmit); return; } const sortButton = event.target.closest('[data-markets-sort-button]'); if (sortButton) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: sortButton.dataset.marketsSortColumn || getSortColumn(), market_dir: sortButton.dataset.marketsSortDirection || getSortDirection(), market_limit: getPageSize() }); return; } const loadMore = event.target.closest('[data-markets-load-more-trigger]'); if (loadMore) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() + getPageSize() }); return; } const trigger = event.target.closest('[data-market-drawer-open]'); if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches && !marketOpeningMobileDetail) { openMarketMobileDetail(trigger); } else { void openMarketDrawer(trigger); } return; } if (event.target.closest('[data-market-drawer-close]')) { closeMarketDrawer(); return; } if (event.target.closest('[data-market-detail-back]')) { if (window.history.state?.marketView === 'detail') { window.history.back(); } else { restoreMarketList(); } return; } });"; diff --git a/public/api/market-occurrence.php b/public/api/market-occurrence.php index cece872..f7c9c5f 100644 --- a/public/api/market-occurrence.php +++ b/public/api/market-occurrence.php @@ -56,6 +56,13 @@ if ($requestMethod === 'POST') { $day = market_management_create_day($pdo, $fields); echo json_encode(['ok' => true, 'day' => $day]); exit; + } elseif (($payload['action'] ?? '') === 'delete_day') { + if (($payload['confirmation'] ?? '') !== 'DELETE') { + throw new InvalidArgumentException('Bestätigung muss exakt DELETE lauten.'); + } + $day = market_management_delete_day($pdo, $id); + echo json_encode(['ok' => true, 'day' => $day]); + exit; } elseif ($scope === 'day') { market_management_update_day($pdo, $id, $fields); } elseif (in_array($scope, ['occurrence', 'status', 'overview', 'participation', 'infrastructure', 'travel', 'notes'], true)) {