pp-planer/resources/js/Components/AgendaItemRow.vue
Thorsten Bus 78b8fc2e3d refactor(ui): convert agenda list to table with proper data formatting
- 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
2026-03-29 16:32:30 +02:00

174 lines
6.5 KiB
Vue

<script setup>
import { computed, ref } from 'vue'
import SlideUploader from '@/Components/SlideUploader.vue'
const props = defineProps({
agendaItem: { type: Object, required: true },
serviceId: { type: Number, required: true },
serviceDate: { type: String, default: null },
})
const emit = defineEmits(['slides-updated'])
const showUploader = ref(false)
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 (props.agendaItem.is_announcement_position) return 'bg-blue-50'
if (props.agendaItem.is_sermon) return 'bg-purple-50'
if (props.agendaItem.slides?.length > 0) return 'bg-emerald-50'
return ''
})
const borderClass = computed(() => {
if (props.agendaItem.is_announcement_position) return 'border-l-4 border-l-blue-400'
if (props.agendaItem.is_sermon) return 'border-l-4 border-l-purple-400'
if (props.agendaItem.slides?.length > 0) return 'border-l-4 border-l-emerald-500'
return ''
})
function onUploaded() {
showUploader.value = false
emit('slides-updated')
}
</script>
<template>
<!-- Main row -->
<tr
class="border-b border-gray-100"
:class="rowBgClass || 'hover:bg-gray-50'"
data-testid="agenda-item-row"
>
<!-- 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">
<span class="font-medium text-gray-900" data-testid="agenda-item-title">{{ agendaItem.title }}</span>
<!-- Badges inline -->
<span
v-if="agendaItem.is_announcement_position"
class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-700"
>
Ankündigungen
</span>
<span
v-if="agendaItem.is_sermon"
class="ml-2 inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-[11px] font-medium text-purple-700"
>
Predigt
</span>
<!-- Note -->
<p v-if="agendaItem.note" class="mt-0.5 text-xs text-gray-400">
{{ agendaItem.note }}
</p>
<!-- Slide thumbnails -->
<div v-if="agendaItem.slides?.length" class="mt-1 flex gap-1">
<img
v-for="slide in agendaItem.slides.slice(0, 4)"
:key="slide.id"
:src="`/storage/slides/${slide.thumbnail_filename}`"
class="h-8 w-14 rounded border border-gray-200 object-cover"
:alt="slide.original_filename"
/>
<span
v-if="agendaItem.slides.length > 4"
class="flex h-8 w-14 items-center justify-center rounded border border-gray-200 text-[10px] text-gray-500"
>
+{{ agendaItem.slides.length - 4 }}
</span>
</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">
<button
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-blue-500"
data-testid="agenda-item-add-slides"
:title="showUploader ? 'Schließen' : 'Folien hinzufügen'"
@click="showUploader = !showUploader"
>
<svg v-if="!showUploader" 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="M12 4.5v15m7.5-7.5h-15" />
</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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</td>
</tr>
<!-- Uploader row (spanning all columns) -->
<tr v-if="showUploader">
<td colspan="6" class="border-b border-gray-100 px-3 pb-3">
<SlideUploader
type="agenda_item"
:service-id="serviceId"
:agenda-item-id="agendaItem.id"
:show-expire-date="false"
:inline="true"
@uploaded="onUploaded"
/>
</td>
</tr>
</template>