pp-planer/resources/js/Components/SongAgendaItem.vue
Thorsten Bus 7a29a21822 feat: confirm-state for service songs + service-edit UX fixes
- add explicit confirmed_at assignment state (red/amber/green) with confirm/unconfirm endpoints
- clone leader arrangement before opening dialog to avoid flicker
- agenda slide strip: show all previews, drag-reorder, number badges, auto-hide uploader
- fix info-slide expire-date saving via axios (was rendering raw JSON modal)
- point top-left logo to / instead of /dashboard
2026-06-21 11:53:55 +02:00

442 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, ref, watch } from 'vue'
import { Link, router } from '@inertiajs/vue3'
const props = defineProps({
agendaItem: { type: Object, required: true },
songsCatalog: { type: Array, default: () => [] },
serviceId: { type: Number, required: true },
})
const emit = defineEmits(['arrangement-selected', 'slides-updated'])
const serviceSong = computed(() => props.agendaItem.serviceSong ?? props.agendaItem.service_song ?? null)
const isMatched = computed(() => serviceSong.value?.song_id != null)
const isConfirmed = computed(() => serviceSong.value?.confirmed_at != null)
// Matched song id for linking to the SongDB edit modal (Songs/Index reads #song-<id>).
const matchedSongId = computed(() => serviceSong.value?.song?.id ?? serviceSong.value?.song_id ?? null)
const songDetailHref = computed(() =>
matchedSongId.value != null ? `${route('songs.index')}#song-${matchedSongId.value}` : null,
)
// Matched song whose default arrangement has NO content slide -> not verified.
const missingContentSlides = computed(
() => isMatched.value && serviceSong.value?.has_content_slides === false,
)
const useTranslation = ref(false)
const downloading = ref(false)
const toastMessage = ref('')
const toastVariant = ref('info')
function showToast(message, variant = 'info') {
toastMessage.value = message
toastVariant.value = variant
setTimeout(() => {
toastMessage.value = ''
}, 2500)
}
function toastClasses() {
if (toastVariant.value === 'success') return 'border-emerald-300 bg-emerald-50 text-emerald-700'
if (toastVariant.value === 'warning') return 'border-amber-300 bg-amber-50 text-amber-700'
if (toastVariant.value === 'error') return 'border-red-300 bg-red-50 text-red-700'
return 'border-slate-300 bg-slate-50 text-slate-700'
}
watch(
() => serviceSong.value,
(ss) => {
if (ss) {
useTranslation.value = ss.use_translation ?? false
}
},
{ immediate: true, deep: true },
)
const responsibleNames = computed(() => {
const r = props.agendaItem.responsible
if (!r) return ''
// CTS returns {text: "...", persons: [{person: {title: "Name"}, service: "[Role]"}]}
if (r.persons && Array.isArray(r.persons)) {
const names = r.persons
.map((p) => p.person?.title || p.name || '')
.filter(Boolean)
if (names.length > 0) return [...new Set(names)].join(', ')
}
// Fallback: use text field
if (r.text) return r.text
// Legacy: array of objects
if (Array.isArray(r)) {
return r.map((p) => p.person?.title || p.name || p.title || p.personName || '').filter(Boolean).join(', ')
}
return ''
})
const formattedStart = computed(() => {
const s = props.agendaItem.start
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' })
})
const formattedDuration = computed(() => {
const dur = props.agendaItem.duration
if (!dur || dur === '0') return ''
const totalSeconds = parseInt(dur, 10)
if (isNaN(totalSeconds) || totalSeconds <= 0) return dur
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}` : `0:${String(minutes).padStart(2, '0')}`
})
const rowBgClass = computed(() => {
if (!isMatched.value) return 'bg-red-50'
if (isConfirmed.value) return 'bg-emerald-50'
return 'bg-amber-50'
})
const borderClass = computed(() => {
if (!isMatched.value) return 'border-l-4 border-l-red-400'
if (isConfirmed.value) return 'border-l-4 border-l-emerald-500'
return 'border-l-4 border-l-amber-400'
})
async function requestCreation() {
try {
await window.axios.post(`/api/service-songs/${serviceSong.value.id}/request`)
showToast('Anfrage wurde gesendet.', 'success')
router.reload({ preserveScroll: true, preserveState: true })
} catch {
showToast('Anfrage konnte nicht gesendet werden.', 'error')
}
}
async function updateUseTranslation() {
try {
await window.axios.patch(`/api/service-songs/${serviceSong.value.id}`, {
use_translation: Boolean(useTranslation.value),
})
showToast('Übersetzung wurde gespeichert.', 'success')
} catch {
showToast('Speichern fehlgeschlagen.', 'error')
}
}
function formatDateTime(value) {
if (!value) return null
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
async function downloadBundle() {
downloading.value = true
try {
const response = await fetch(
route('services.agenda-item.download', {
service: props.serviceId,
agendaItem: props.agendaItem.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.', 'error')
return
}
const disposition = response.headers.get('content-disposition')
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
const filename = filenameMatch?.[1] || 'song.probundle'
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('Download gestartet.', 'success')
} catch {
showToast('Fehler beim Herunterladen.', 'error')
} finally {
downloading.value = false
}
}
async function confirmAssignment() {
try {
await window.axios.post(`/api/service-songs/${serviceSong.value.id}/confirm`)
showToast('Zuordnung bestätigt.', 'success')
router.reload({ preserveScroll: true, preserveState: true })
} catch {
showToast('Bestätigen fehlgeschlagen.', 'error')
}
}
async function unconfirmAssignment() {
try {
await window.axios.post(`/api/service-songs/${serviceSong.value.id}/unconfirm`)
showToast('Bestätigung aufgehoben.', 'success')
router.reload({ preserveScroll: true, preserveState: true })
} catch {
showToast('Aufheben fehlgeschlagen.', 'error')
}
}
</script>
<template>
<!-- Toast row -->
<tr v-if="toastMessage">
<td colspan="6" class="px-3 py-2">
<div
class="rounded-lg border px-4 py-2 text-sm font-medium"
:class="toastClasses()"
role="status"
>
{{ toastMessage }}
</div>
</td>
</tr>
<!-- Main song row -->
<tr
class="border-b border-gray-100"
:class="rowBgClass"
data-testid="song-agenda-item"
>
<!-- Nr -->
<td class="py-2.5 pr-3 align-top" :class="borderClass">
<span class="text-xs text-gray-400 tabular-nums">{{ agendaItem.position || agendaItem.sort_order }}</span>
</td>
<!-- Zeit -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedStart" class="text-xs text-gray-500 tabular-nums">{{ formattedStart }}</span>
</td>
<!-- Dauer -->
<td class="py-2.5 pr-3 align-top">
<span v-if="formattedDuration" class="text-xs text-gray-500 tabular-nums">{{ formattedDuration }}</span>
</td>
<!-- Titel -->
<td class="py-2.5 pr-3 align-top">
<div class="flex items-center gap-2 flex-wrap">
<!-- Song title -->
<Link
v-if="isMatched && songDetailHref"
:href="songDetailHref"
class="font-medium text-gray-900 underline decoration-gray-300 decoration-dotted underline-offset-2 transition hover:text-emerald-700 hover:decoration-emerald-400"
data-testid="song-agenda-title"
title="Song in der Song-Datenbank öffnen"
>
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
</Link>
<span v-else class="font-medium text-gray-900" data-testid="song-agenda-title">
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
</span>
<!-- No content slides warning (matched but default arrangement empty) -->
<span
v-if="missingContentSlides"
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-700"
data-testid="song-no-content"
title="Standard-Arrangement enthält keine Inhaltsfolien"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Keine Inhaltsfolien
</span>
<!-- CCLI pill -->
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500">
CCLI: {{ serviceSong?.song?.ccli_id || serviceSong?.cts_ccli_id || '-' }}
</span>
<!-- Note -->
<p v-if="agendaItem.note" class="mt-0.5 basis-full text-xs text-gray-400">
{{ agendaItem.note }}
</p>
</div>
</td>
<!-- Verantwortlich -->
<td class="py-2.5 pr-3 align-top">
<span v-if="responsibleNames" class="text-xs text-gray-500">{{ responsibleNames }}</span>
</td>
<!-- Aktionen -->
<td class="py-2.5 align-top">
<div class="flex items-center justify-end gap-1">
<!-- Additional red overlay: no content slides -->
<span
v-if="missingContentSlides"
class="inline-flex h-2 w-2 rounded-full bg-red-500"
title="Keine Inhaltsfolien"
></span>
<!-- 3-state status icon -->
<!-- not matched -> link-slash, red -->
<svg
v-if="!isMatched"
class="h-4 w-4 text-red-500"
data-testid="song-status-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
title="Nicht zugeordnet Song zuordnen"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13.181 8.68a4.5 4.5 0 011.044 7.158l-1.99 1.99a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-1.99 1.99M3 3l18 18" />
</svg>
<!-- matched but not confirmed -> outline check-circle, amber -->
<svg
v-else-if="!isConfirmed"
class="h-4 w-4 text-amber-500"
data-testid="song-status-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
title="Zu bestätigen"
>
<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>
<!-- confirmed -> solid check-circle, emerald -->
<svg
v-else
class="h-4 w-4 text-emerald-600"
data-testid="song-status-icon"
fill="currentColor"
viewBox="0 0 24 24"
title="Bestätigt"
>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" />
</svg>
<!-- Translation flag -->
<span
v-if="serviceSong?.song?.has_translation"
class="text-sm leading-none"
title="Mit Übersetzung"
>🇩🇪🇬🇧</span>
<span
v-else
class="text-sm leading-none"
title="Nur Deutsch"
>🇩🇪</span>
<!-- Translation checkbox (only when matched + has translation) -->
<label
v-if="isMatched && serviceSong?.song?.has_translation"
class="inline-flex items-center"
title="Übersetzung verwenden"
>
<input
v-model="useTranslation"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
data-testid="song-translation-checkbox"
@change="updateUseTranslation"
/>
</label>
<!-- Download bundle -->
<button
v-if="isMatched"
type="button"
class="flex h-7 w-7 items-center justify-center rounded text-gray-400 transition hover:bg-emerald-100 hover:text-emerald-600 disabled:opacity-50"
data-testid="song-download-bundle"
title="Als .probundle herunterladen"
:disabled="downloading"
@click="downloadBundle"
>
<svg v-if="downloading" 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>
</button>
<!-- Confirm assignment (matched + arrangement set + not yet confirmed) -->
<button
v-if="isMatched && serviceSong?.song_arrangement_id && !isConfirmed"
type="button"
class="inline-flex items-center gap-1 rounded-md border border-emerald-300 bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700 transition hover:bg-emerald-100"
data-testid="song-confirm-assignment"
title="Zuordnung bestätigen"
@click="confirmAssignment"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
Bestätigen
</button>
<!-- Unconfirm (revert) — subtle, when confirmed -->
<button
v-else-if="isMatched && isConfirmed"
type="button"
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
data-testid="song-unconfirm-assignment"
title="Bestätigung aufheben"
@click="unconfirmAssignment"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42" />
</svg>
Bestätigt
</button>
<!-- Edit / assign song — opens dialog for both matched and unmatched -->
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
data-testid="song-edit-arrangement"
:title="isMatched ? 'Arrangement bearbeiten' : 'Song zuordnen'"
@click="emit('arrangement-selected', serviceSong)"
>
<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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<!-- Request creation button (only when not matched) -->
<button
v-if="!isMatched && serviceSong"
type="button"
class="flex h-7 w-7 items-center justify-center rounded text-amber-500 transition hover:bg-amber-50 hover:text-amber-700"
data-testid="song-request-creation"
:title="serviceSong.request_sent_at ? 'Anfrage gesendet am ' + formatDateTime(serviceSong.request_sent_at) : 'Erstellung anfragen'"
@click="requestCreation"
>
<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="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</button>
</div>
</td>
</tr>
</template>