- Replace card-based agenda layout with a 6-column table (Nr, Zeit, Dauer, Titel, Verantwortlich, Aktionen)
- Fix isHeaderType to only match type=header, not all non-song items
- Format start time from ISO 8601 to HH:MM (Europe/Berlin)
- Format duration from seconds string to H:MM
- Fix responsible parsing for CTS data structure ({text, persons[{person:{title}}]})
- Move unmatched song search/assign UI into ArrangementDialog popup
- Add colored row backgrounds (song/sermon/announcement/slides) with left border
- Invert header rows to dark grey background with white text
261 lines
11 KiB
Vue
261 lines
11 KiB
Vue
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import { 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 useTranslation = 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-amber-50'
|
|
if (serviceSong.value?.song_arrangement_id) return 'bg-emerald-50'
|
|
return 'bg-yellow-50'
|
|
})
|
|
|
|
const borderClass = computed(() => {
|
|
if (!isMatched.value) return 'border-l-4 border-l-amber-400'
|
|
if (serviceSong.value?.song_arrangement_id) return 'border-l-4 border-l-emerald-500'
|
|
return 'border-l-4 border-l-yellow-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',
|
|
})
|
|
}
|
|
</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 -->
|
|
<span class="font-medium text-gray-900" data-testid="song-agenda-title">
|
|
{{ serviceSong?.cts_song_name || agendaItem.title || '-' }}
|
|
</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">
|
|
<!-- Assign status -->
|
|
<span
|
|
v-if="isMatched"
|
|
class="inline-flex h-2 w-2 rounded-full bg-emerald-500"
|
|
title="Zugeordnet"
|
|
></span>
|
|
<span
|
|
v-else
|
|
class="inline-flex h-2 w-2 rounded-full bg-amber-400"
|
|
title="Nicht zugeordnet"
|
|
></span>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|