pp-planer/resources/js/Pages/Services/Edit.vue

663 lines
31 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 ModerationBlock from '@/Components/Blocks/ModerationBlock.vue'
import SongsBlock from '@/Components/Blocks/SongsBlock.vue'
import SermonBlock from '@/Components/Blocks/SermonBlock.vue'
const props = defineProps({
service: {
type: Object,
required: true,
},
serviceSongs: {
type: Array,
default: () => [],
},
informationSlides: {
type: Array,
default: () => [],
},
moderationSlides: {
type: Array,
default: () => [],
},
sermonSlides: {
type: Array,
default: () => [],
},
songsCatalog: {
type: Array,
default: () => [],
},
prevService: {
type: Object,
default: null,
},
nextService: {
type: Object,
default: null,
},
})
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',
})
}
/* ── Collapsible block state ─────────────────────────────── */
const expandedBlocks = ref({
information: true,
moderation: true,
sermon: true,
songs: true,
})
function toggleBlock(key) {
expandedBlocks.value[key] = !expandedBlocks.value[key]
}
function goBack() {
router.get(route('services.index'))
}
function refreshPage() {
router.reload({ preserveScroll: true })
}
/* ── Block definitions ───────────────────────────────────── */
const blocks = [
{
key: 'information',
label: 'Information',
description: 'Info-Folien fuer alle kommenden Services',
icon: 'info',
accentFrom: 'from-sky-400',
accentTo: 'to-blue-500',
badgeColor: 'bg-sky-100 text-sky-700',
},
{
key: 'moderation',
label: 'Moderation',
description: 'Moderationsfolien fuer diesen Service',
icon: 'moderation',
accentFrom: 'from-violet-400',
accentTo: 'to-purple-500',
badgeColor: 'bg-violet-100 text-violet-700',
},
{
key: 'sermon',
label: 'Predigt',
description: 'Predigtfolien fuer diesen Service',
icon: 'sermon',
accentFrom: 'from-amber-400',
accentTo: 'to-orange-500',
badgeColor: 'bg-amber-100 text-amber-700',
},
{
key: 'songs',
label: 'Songs',
description: 'Songs und Arrangements verwalten',
icon: 'songs',
accentFrom: 'from-emerald-400',
accentTo: 'to-teal-500',
badgeColor: 'bg-emerald-100 text-emerald-700',
},
]
function blockSlideCount(key) {
if (key === 'information') return props.informationSlides.length
if (key === 'moderation') return props.moderationSlides.length
if (key === 'sermon') return props.sermonSlides.length
if (key === 'songs') return props.serviceSongs.length
return 0
}
function blockBadgeLabel(key) {
const count = blockSlideCount(key)
if (key === 'songs') return `${count} Song${count !== 1 ? 's' : ''}`
return `${count} Folie${count !== 1 ? 'n' : ''}`
}
/* ── 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">&middot;</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>
<div class="py-8">
<div class="mx-auto max-w-5xl px-4 pb-24 sm:px-6 lg:px-8">
<!-- Block accordion sections -->
<div class="space-y-4">
<div
v-for="block in blocks"
:key="block.key"
class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm transition-shadow hover:shadow-md"
>
<!-- Block header (clickable) -->
<button
data-testid="service-edit-block-toggle"
type="button"
class="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-gray-50/60"
@click="toggleBlock(block.key)"
>
<!-- Accent icon -->
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br shadow-sm"
:class="[block.accentFrom, block.accentTo]"
>
<!-- Information icon -->
<svg v-if="block.icon === 'info'" 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>
<!-- Moderation icon -->
<svg v-else-if="block.icon === 'moderation'" 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="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
</svg>
<!-- Sermon icon -->
<svg v-else-if="block.icon === 'sermon'" 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="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
<!-- Songs icon -->
<svg v-else-if="block.icon === 'songs'" 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="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
</svg>
</div>
<!-- Label + description -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2.5">
<h3 class="text-[15px] font-semibold text-gray-900">
{{ block.label }}
</h3>
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
:class="block.badgeColor"
>
{{ blockBadgeLabel(block.key) }}
</span>
</div>
<p class="mt-0.5 text-xs text-gray-500">
{{ block.description }}
</p>
</div>
<!-- Chevron -->
<svg
class="h-5 w-5 shrink-0 text-gray-400 transition-transform duration-200"
:class="{ 'rotate-180': expandedBlocks[block.key] }"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<!-- Block content (collapsible) -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="max-h-0 opacity-0"
enter-to-class="max-h-[2000px] opacity-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="max-h-[2000px] opacity-100"
leave-to-class="max-h-0 opacity-0"
>
<div v-show="expandedBlocks[block.key]" class="overflow-hidden">
<div class="border-t border-gray-100 px-5 py-6">
<InformationBlock
v-if="block.key === 'information'"
:service-id="service.id"
:service-date="service.date"
:slides="informationSlides"
@slides-updated="refreshPage"
/>
<ModerationBlock
v-else-if="block.key === 'moderation'"
:service-id="service.id"
:slides="moderationSlides"
@slides-updated="refreshPage"
/>
<SermonBlock
v-else-if="block.key === 'sermon'"
:service-id="service.id"
:slides="sermonSlides"
@slides-updated="refreshPage"
/>
<SongsBlock
v-else-if="block.key === 'songs'"
:service-songs="serviceSongs"
:songs-catalog="songsCatalog"
/>
<div
v-else
class="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-200 bg-gray-50/50 py-12"
>
<div class="text-center">
<div
class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br opacity-20"
:class="[block.accentFrom, block.accentTo]"
>
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<p class="mt-3 text-sm font-medium text-gray-400">
{{ block.label }}-Block wird hier angezeigt
</p>
<p class="mt-1 text-xs text-gray-300">
Platzhalter — Komponente folgt
</p>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
</div>
<!-- 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 &amp; 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>