- Add virtual MASTER arrangement to all songs (read-only, shows all groups in song order, clonable) - Fix drop zone staying open after slide upload and thumbnail 403 (double slides/ path) - Add click-to-preview overlay with download button, prev/next navigation, and slide counter - Add X delete button with confirmation dialog and hover tooltip preview on agenda thumbnails - Fix arrangement select not updating after add/clone (emit + re-fetch pattern) - Move InformationBlock below agenda; announcement row scrolls to it instead of showing upload - Create storage symlink (public/storage -> storage/app/public)
617 lines
27 KiB
Vue
617 lines
27 KiB
Vue
<script setup>
|
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
|
import { Head, router } from '@inertiajs/vue3'
|
|
import { ref, computed } from 'vue'
|
|
import InformationBlock from '@/Components/Blocks/InformationBlock.vue'
|
|
import AgendaItemRow from '@/Components/AgendaItemRow.vue'
|
|
import SongAgendaItem from '@/Components/SongAgendaItem.vue'
|
|
import ArrangementDialog from '@/Components/ArrangementDialog.vue'
|
|
|
|
const props = defineProps({
|
|
service: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
serviceSongs: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
informationSlides: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
songsCatalog: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
prevService: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
nextService: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
agendaItems: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
agendaSettings: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
})
|
|
|
|
const formattedDate = computed(() => {
|
|
if (!props.service.date) return ''
|
|
return new Date(props.service.date).toLocaleDateString('de-DE', {
|
|
weekday: 'long',
|
|
day: '2-digit',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
})
|
|
})
|
|
|
|
function formatShortDate(dateStr) {
|
|
if (!dateStr) return ''
|
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function goBack() {
|
|
router.get(route('services.index'))
|
|
}
|
|
|
|
const informationBlockRef = ref(null)
|
|
|
|
function refreshPage() {
|
|
router.reload({ preserveScroll: true })
|
|
}
|
|
|
|
function scrollToInfoBlock() {
|
|
informationBlockRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}
|
|
|
|
/* ── Agenda helpers ──────────────────────────────────────── */
|
|
|
|
const arrangementDialogItem = ref(null)
|
|
|
|
function openArrangementDialog(item) {
|
|
arrangementDialogItem.value = item
|
|
}
|
|
|
|
function getArrangements(item) {
|
|
const song = item.service_song?.song ?? item.serviceSong?.song
|
|
if (!song?.arrangements) return []
|
|
|
|
return song.arrangements.map((arr) => ({
|
|
id: arr.id,
|
|
name: arr.name,
|
|
is_default: arr.is_default,
|
|
groups: (arr.arrangement_groups ?? []).map((ag) => {
|
|
const group = song.groups?.find((g) => g.id === ag.song_group_id) ?? ag.group ?? {}
|
|
return {
|
|
id: ag.song_group_id ?? group.id,
|
|
name: group.name ?? 'Unbekannt',
|
|
color: group.color ?? '#6b7280',
|
|
order: ag.order,
|
|
slides: group.slides ?? [],
|
|
}
|
|
}),
|
|
}))
|
|
}
|
|
|
|
function getAvailableGroups(item) {
|
|
const song = item.service_song?.song ?? item.serviceSong?.song
|
|
if (!song?.groups) return []
|
|
|
|
return song.groups.map((group) => ({
|
|
id: group.id,
|
|
name: group.name,
|
|
color: group.color,
|
|
slides: group.slides ?? [],
|
|
}))
|
|
}
|
|
|
|
function getSelectedArrangementId(item) {
|
|
return item.service_song?.song_arrangement_id ?? item.serviceSong?.song_arrangement_id ?? null
|
|
}
|
|
|
|
function isHeaderType(item) {
|
|
return item.type?.toLowerCase() === 'header'
|
|
}
|
|
|
|
function onArrangementDialogClosed() {
|
|
arrangementDialogItem.value = null
|
|
router.reload({ preserveScroll: true })
|
|
}
|
|
|
|
/* ── Finalize / Reopen / Download ───────────────────────── */
|
|
const toastMessage = ref('')
|
|
const toastType = ref('info')
|
|
const confirmDialog = ref(false)
|
|
const confirmWarnings = ref([])
|
|
const finalizing = ref(false)
|
|
const downloadAfterFinalize = ref(false)
|
|
|
|
const isFinalized = computed(() => !!props.service.finalized_at)
|
|
|
|
function formatDateTime(dateStr) {
|
|
if (!dateStr) return ''
|
|
const d = new Date(dateStr)
|
|
return d.toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
toastMessage.value = message
|
|
toastType.value = type
|
|
setTimeout(() => {
|
|
toastMessage.value = ''
|
|
}, 3500)
|
|
}
|
|
|
|
function toastClasses() {
|
|
if (toastType.value === 'success') return 'border-emerald-300 bg-emerald-50 text-emerald-700'
|
|
if (toastType.value === 'warning') return 'border-orange-300 bg-orange-50 text-orange-700'
|
|
return 'border-blue-300 bg-blue-50 text-blue-700'
|
|
}
|
|
|
|
async function finalizeService(andDownload = false) {
|
|
if (andDownload) downloadAfterFinalize.value = true
|
|
finalizing.value = true
|
|
try {
|
|
const response = await fetch(route('services.finalize', props.service.id), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
|
},
|
|
body: JSON.stringify({ confirmed: false }),
|
|
})
|
|
const data = await response.json()
|
|
|
|
if (data.needs_confirmation) {
|
|
confirmWarnings.value = data.warnings
|
|
confirmDialog.value = true
|
|
return
|
|
}
|
|
|
|
props.service.finalized_at = new Date().toISOString()
|
|
showToast(data.success, 'success')
|
|
|
|
if (downloadAfterFinalize.value) {
|
|
downloadAfterFinalize.value = false
|
|
await downloadService()
|
|
}
|
|
} finally {
|
|
finalizing.value = false
|
|
}
|
|
}
|
|
|
|
async function confirmFinalize() {
|
|
finalizing.value = true
|
|
confirmDialog.value = false
|
|
try {
|
|
const response = await fetch(route('services.finalize', props.service.id), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
|
},
|
|
body: JSON.stringify({ confirmed: true }),
|
|
})
|
|
const data = await response.json()
|
|
|
|
props.service.finalized_at = new Date().toISOString()
|
|
showToast(data.success, 'success')
|
|
|
|
if (downloadAfterFinalize.value) {
|
|
downloadAfterFinalize.value = false
|
|
await downloadService()
|
|
}
|
|
} finally {
|
|
finalizing.value = false
|
|
confirmWarnings.value = []
|
|
}
|
|
}
|
|
|
|
function cancelFinalize() {
|
|
confirmDialog.value = false
|
|
confirmWarnings.value = []
|
|
downloadAfterFinalize.value = false
|
|
}
|
|
|
|
async function reopenService() {
|
|
try {
|
|
const response = await fetch(route('services.reopen', props.service.id), {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
|
},
|
|
})
|
|
if (response.ok) {
|
|
props.service.finalized_at = null
|
|
showToast('Service wurde wieder geöffnet.', 'success')
|
|
} else {
|
|
showToast('Fehler beim Öffnen.', 'warning')
|
|
}
|
|
} catch {
|
|
showToast('Fehler beim Öffnen.', 'warning')
|
|
}
|
|
}
|
|
|
|
async function downloadService() {
|
|
try {
|
|
const response = await fetch(route('services.download', props.service.id), {
|
|
headers: {
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}))
|
|
showToast(data.message || 'Fehler beim Herunterladen.', 'warning')
|
|
return
|
|
}
|
|
|
|
const disposition = response.headers.get('content-disposition')
|
|
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
|
|
const filename = filenameMatch?.[1] || 'playlist.proplaylist'
|
|
|
|
const blob = await response.blob()
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
|
|
showToast('Playlist wurde heruntergeladen.', 'success')
|
|
} catch {
|
|
showToast('Fehler beim Herunterladen.', 'warning')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="`${service.title} bearbeiten`" />
|
|
|
|
<AuthenticatedLayout>
|
|
<template #header>
|
|
<div class="flex items-center gap-3">
|
|
<!-- Back button -->
|
|
<button
|
|
data-testid="service-edit-back-icon-button"
|
|
type="button"
|
|
class="group flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-600"
|
|
@click="goBack"
|
|
title="Zurueck zur Uebersicht"
|
|
>
|
|
<svg class="h-4 w-4 transition-transform group-hover:-translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
|
</svg>
|
|
</button>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
v-if="prevService"
|
|
type="button"
|
|
class="group flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 shadow-sm transition hover:border-amber-300 hover:bg-amber-50"
|
|
@click="router.get(route('services.edit', prevService.id))"
|
|
:title="prevService.title"
|
|
>
|
|
<svg class="h-3.5 w-3.5 text-gray-400 transition group-hover:text-amber-500 group-hover:-translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
|
</svg>
|
|
<div class="text-left">
|
|
<span class="block max-w-32 truncate leading-tight">{{ prevService.title }}</span>
|
|
<span class="block text-[10px] text-gray-400 leading-tight">{{ formatShortDate(prevService.date) }}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Service title -->
|
|
<div class="min-w-0 flex-1">
|
|
<h2 class="truncate text-xl font-semibold leading-tight text-gray-900">
|
|
{{ service.title }}
|
|
</h2>
|
|
<p class="mt-0.5 text-sm text-gray-500">
|
|
{{ formattedDate }}
|
|
<template v-if="service.preacher_name">
|
|
<span class="mx-1.5 text-gray-300">·</span>
|
|
<span>{{ service.preacher_name }}</span>
|
|
</template>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
v-if="nextService"
|
|
type="button"
|
|
class="group flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 shadow-sm transition hover:border-amber-300 hover:bg-amber-50"
|
|
@click="router.get(route('services.edit', nextService.id))"
|
|
:title="nextService.title"
|
|
>
|
|
<div class="text-right">
|
|
<span class="block max-w-32 truncate leading-tight">{{ nextService.title }}</span>
|
|
<span class="block text-[10px] text-gray-400 leading-tight">{{ formatShortDate(nextService.date) }}</span>
|
|
</div>
|
|
<svg class="h-3.5 w-3.5 text-gray-400 transition group-hover:text-amber-500 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Ablauf (Agenda) -->
|
|
<div class="py-6">
|
|
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
|
<h3 class="mb-4 text-base font-semibold text-gray-900">Ablauf</h3>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="!agendaItems || agendaItems.length === 0"
|
|
class="rounded-lg border border-dashed border-gray-300 p-8 text-center text-sm text-gray-500">
|
|
Keine Ablauf-Elemente vorhanden. Bitte synchronisiere die Daten zuerst.
|
|
</div>
|
|
|
|
<!-- Agenda table -->
|
|
<table v-else class="w-full text-sm" data-testid="agenda-section">
|
|
<thead>
|
|
<tr class="border-b border-gray-200 text-left text-xs font-semibold uppercase tracking-wider text-gray-400">
|
|
<th class="py-2 pr-3 w-12">Nr</th>
|
|
<th class="py-2 pr-3 w-20">Zeit</th>
|
|
<th class="py-2 pr-3 w-20">Dauer</th>
|
|
<th class="py-2 pr-3">Titel</th>
|
|
<th class="py-2 pr-3 w-40">Verantwortlich</th>
|
|
<th class="py-2 w-24"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="item in agendaItems" :key="item.id">
|
|
<!-- Title/header items -->
|
|
<tr v-if="isHeaderType(item)"
|
|
class="bg-gray-600"
|
|
data-testid="agenda-header-item">
|
|
<td colspan="6" class="px-3 py-1.5">
|
|
<h4 class="text-xs font-semibold uppercase tracking-wider text-white">
|
|
{{ item.title }}
|
|
</h4>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Song items -->
|
|
<SongAgendaItem
|
|
v-else-if="item.service_song_id !== null || item.type === 'Song'"
|
|
:agenda-item="item"
|
|
:songs-catalog="songsCatalog"
|
|
:service-id="service.id"
|
|
@arrangement-selected="openArrangementDialog(item)"
|
|
@slides-updated="refreshPage"
|
|
/>
|
|
|
|
<!-- Generic agenda items -->
|
|
<AgendaItemRow
|
|
v-else
|
|
:agenda-item="item"
|
|
:service-id="service.id"
|
|
:service-date="service.date"
|
|
@slides-updated="refreshPage"
|
|
@scroll-to-info="scrollToInfoBlock"
|
|
/>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Information Block (below agenda, scrolled to from announcement row) -->
|
|
<div ref="informationBlockRef" class="py-6">
|
|
<div class="mx-auto max-w-4xl px-4 pb-24 sm:px-6 lg:px-8">
|
|
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white p-5 shadow-sm">
|
|
<div class="mb-3 flex items-center gap-3">
|
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-500 shadow-sm">
|
|
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-[15px] font-semibold text-gray-900">Information</h3>
|
|
<p class="text-xs text-gray-500">Info-Folien für alle kommenden Services</p>
|
|
</div>
|
|
</div>
|
|
<InformationBlock
|
|
:service-id="service.id"
|
|
:service-date="service.date"
|
|
:slides="informationSlides"
|
|
@slides-updated="refreshPage"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Arrangement Dialog -->
|
|
<ArrangementDialog
|
|
v-if="arrangementDialogItem"
|
|
:song-id="arrangementDialogItem.service_song?.song_id ?? arrangementDialogItem.serviceSong?.song_id"
|
|
:arrangements="getArrangements(arrangementDialogItem)"
|
|
:available-groups="getAvailableGroups(arrangementDialogItem)"
|
|
:selected-arrangement-id="getSelectedArrangementId(arrangementDialogItem)"
|
|
:service-song-id="arrangementDialogItem.service_song?.id ?? arrangementDialogItem.serviceSong?.id"
|
|
:songs-catalog="songsCatalog"
|
|
@close="onArrangementDialogClosed"
|
|
/>
|
|
|
|
<!-- Sticky bottom action bar -->
|
|
<div data-testid="service-edit-action-bar" class="fixed bottom-0 left-0 right-0 z-30">
|
|
<div class="border-t border-gray-200 bg-white/90 backdrop-blur-xl shadow-[0_-2px_12px_rgba(0,0,0,0.08)]">
|
|
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3.5 sm:px-6 lg:px-8">
|
|
<!-- Status indicator -->
|
|
<div class="flex items-center gap-2.5 text-sm">
|
|
<template v-if="isFinalized">
|
|
<span class="relative flex h-2.5 w-2.5">
|
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-40"></span>
|
|
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500"></span>
|
|
</span>
|
|
<span class="font-semibold text-emerald-700">Abgeschlossen</span>
|
|
<span class="text-xs text-gray-400">{{ formatDateTime(service.finalized_at) }}</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="inline-flex h-2.5 w-2.5 rounded-full bg-amber-400"></span>
|
|
<span class="font-semibold text-amber-700">In Bearbeitung</span>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Action buttons -->
|
|
<div class="flex items-center gap-2.5">
|
|
<template v-if="isFinalized">
|
|
<button
|
|
data-testid="service-edit-reopen-button"
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
|
|
@click="reopenService"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
|
</svg>
|
|
Wieder öffnen
|
|
</button>
|
|
<button
|
|
data-testid="service-edit-download-button"
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 rounded-lg border border-emerald-200 bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700"
|
|
@click="downloadService"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
</svg>
|
|
Herunterladen
|
|
</button>
|
|
</template>
|
|
<template v-else>
|
|
<button
|
|
data-testid="service-edit-finalize-button"
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 rounded-lg border border-emerald-200 bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="finalizing"
|
|
@click="finalizeService(false)"
|
|
>
|
|
<svg v-if="finalizing" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
Abschließen
|
|
</button>
|
|
<button
|
|
data-testid="service-edit-finalize-download-button"
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 rounded-lg border border-emerald-200 bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="finalizing"
|
|
@click="finalizeService(true)"
|
|
>
|
|
<svg v-if="finalizing" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
</svg>
|
|
Abschließen & Herunterladen
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast notification -->
|
|
<Teleport to="body">
|
|
<Transition
|
|
enter-active-class="transition duration-300 ease-out"
|
|
enter-from-class="translate-y-2 opacity-0"
|
|
enter-to-class="translate-y-0 opacity-100"
|
|
leave-active-class="transition duration-200 ease-in"
|
|
leave-from-class="translate-y-0 opacity-100"
|
|
leave-to-class="translate-y-2 opacity-0"
|
|
>
|
|
<div
|
|
v-if="toastMessage"
|
|
class="fixed bottom-20 left-1/2 z-50 -translate-x-1/2 rounded-lg border px-5 py-3 text-sm font-medium shadow-lg"
|
|
:class="toastClasses()"
|
|
>
|
|
{{ toastMessage }}
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<!-- Confirmation Dialog -->
|
|
<Teleport to="body">
|
|
<Transition
|
|
enter-active-class="transition duration-200 ease-out"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition duration-150 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-if="confirmDialog"
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
@click.self="cancelFinalize"
|
|
>
|
|
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
|
<h3 class="text-lg font-semibold text-gray-900">Service abschließen?</h3>
|
|
<p class="mt-2 text-sm text-gray-600">
|
|
Es gibt noch offene Punkte. Möchtest du trotzdem abschließen?
|
|
</p>
|
|
|
|
<ul class="mt-3 space-y-1.5">
|
|
<li
|
|
v-for="(warning, idx) in confirmWarnings"
|
|
:key="idx"
|
|
class="flex items-start gap-2 text-sm text-amber-700"
|
|
>
|
|
<span class="mt-0.5 shrink-0" aria-hidden="true">⚠</span>
|
|
<span>{{ warning }}</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="mt-5 flex justify-end gap-3">
|
|
<button
|
|
data-testid="service-edit-confirm-cancel-button"
|
|
type="button"
|
|
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
|
@click="cancelFinalize"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
data-testid="service-edit-confirm-submit-button"
|
|
type="button"
|
|
class="rounded-md border border-emerald-300 bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-700"
|
|
@click="confirmFinalize"
|
|
>
|
|
Trotzdem abschließen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</AuthenticatedLayout>
|
|
</template>
|