Files
FEDEO/frontend/pages/communication/collaboration.vue

174 lines
17 KiB
Vue
Raw Permalink 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>
const { $api } = useNuxtApp()
const route = useRoute()
const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' })
const contacts = ref([]), selected = ref(typeof route.query.room === 'string' ? route.query.room : ''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
const management = ref({ members: [], invitations: [] }), inviteResult = ref(null)
const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') })
const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true })
const details = reactive({ name: '', topic: '', status: 'active', overview: { title: '', status: '', date: '', description: '' } })
const federation = ref(null), partnerUrl = ref('')
const selectedManaged = computed(() => state.value.managedRooms.find(r => r.id === selected.value))
const visibleRooms = computed(() => {
const rooms = new Map(state.value.rooms.map(r => [r.id, r]))
state.value.managedRooms.filter(r => !route.query.project || Number(r.projectId) === Number(route.query.project)).forEach(r => rooms.set(r.id, { ...rooms.get(r.id), ...r }))
return [...rooms.values()]
})
const readable = computed(() => state.value.rooms.some(r => r.id === selected.value))
const message = e => e?.data?.message || e?.data?.error || 'Die Aktion konnte nicht ausgeführt werden.'
async function refresh() {
try {
state.value = await $api('/api/collaboration')
if (!selected.value && visibleRooms.value.length) selected.value = visibleRooms.value[0].id
} catch (e) { error.value = message(e) }
}
async function run(fn) {
busy.value = true; error.value = ''
try { await fn() } catch (e) { error.value = message(e) } finally { busy.value = false }
}
async function createRoom() {
await run(async () => {
const room = await $api('/api/collaboration/rooms', { method: 'POST', body: { ...create, projectId: create.projectId ? Number(create.projectId) : null } })
await refresh(); selected.value = room.id; showCreate.value = false; create.name = ''; create.topic = ''
})
}
async function loadManagement() {
if (!selectedManaged.value) return
const roomId = selected.value
const [result, people] = await Promise.all([$api(`/api/collaboration/rooms/${roomId}/management`), $api('/api/collaboration/contacts')])
if (roomId !== selected.value) return
management.value = result; contacts.value = people.contacts
Object.assign(details, { name: selectedManaged.value.name, topic: selectedManaged.value.topic, status: selectedManaged.value.status, overview: { title: '', status: '', date: '', description: '', ...selectedManaged.value.overview } })
}
async function invitePerson() {
await run(async () => {
inviteResult.value = await $api(`/api/collaboration/rooms/${selected.value}/invitations`, { method: 'POST', body: { ...invite, expiresAt: invite.expiresAt ? new Date(`${invite.expiresAt}T23:59:59`).toISOString() : null } })
await loadManagement(); await refresh()
})
}
async function respond(id, accept) {
await run(async () => {
const result = await $api(`/api/collaboration/invitations/${id}/${accept ? 'accept' : 'decline'}`, { method: 'POST' })
await refresh(); if (accept && result.roomId) selected.value = result.roomId
})
}
async function revoke(id, invitation = false) {
await run(async () => {
await $api(`/api/collaboration/rooms/${selected.value}/${invitation ? 'invitations' : 'members'}/${id}`, { method: 'DELETE' })
await loadManagement(); await refresh()
})
}
async function saveRoom() {
await run(async () => {
await $api(`/api/collaboration/rooms/${selected.value}`, { method: 'PATCH', body: details })
await refresh(); await loadManagement()
})
}
async function copy(value) { await run(() => navigator.clipboard.writeText(value)) }
async function partners() { await run(async () => { federation.value = await $api('/api/collaboration/federation') }) }
async function savePartner(origin, active = true) {
await run(async () => {
federation.value = await $api('/api/collaboration/federation', { method: 'POST', body: { origin, active } })
partnerUrl.value = ''
})
}
watch(selected, () => { showManagement.value = false; inviteResult.value = null; management.value = { members: [], invitations: [] } })
watch(() => route.query.room, id => { if (typeof id === 'string') selected.value = id })
let timer
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="min-h-0 min-w-0 w-full flex-1 space-y-5 overflow-y-auto p-4 sm:p-6">
<header class="flex flex-wrap items-start justify-between gap-3">
<div><h1 class="text-2xl font-semibold">Zusammenarbeit</h1><p class="mt-1 text-sm text-gray-500">Gemeinsame Chats und geschützte Projekt-Unterräume</p></div>
<div class="flex flex-wrap gap-2">
<UButton v-if="state.contact" variant="outline" @click="copy(state.contact)">Meine Kontaktadresse kopieren</UButton>
<UButton v-if="state.canManage" variant="outline" @click="partners">Partnerinstanzen</UButton>
<UButton v-if="state.canManage" @click="showCreate = !showCreate">Unterraum erstellen</UButton>
</div>
</header>
<p v-if="error" role="alert" class="rounded-lg bg-red-50 p-3 text-red-700">{{ error }}</p>
<section v-if="federation" class="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div class="flex justify-between"><h2 class="font-semibold">Freigegebene Partnerinstanzen</h2><button @click="federation = null">Schließen</button></div>
<p class="my-2 text-sm text-gray-500">Beide Firmen geben die jeweils andere Instanz frei. Einzelne Personen erhalten anschließend eine eigene Einladung.</p>
<p v-if="!federation.enabled" class="my-2 text-sm text-amber-700">{{ federation.disabledReason || 'Die Föderation muss zuerst vom Betreiber aktiviert werden.' }}</p>
<form v-if="federation.enabled" class="flex flex-wrap gap-2" @submit.prevent="savePartner(partnerUrl)"><label class="flex-1"><span class="sr-only">Adresse der Partnerinstanz</span><input v-model="partnerUrl" type="url" required placeholder="https://partner.example/backend" class="field" /></label><UButton type="submit" :loading="busy">Partner freigeben</UButton></form>
<div v-for="partner in federation.partners" :key="partner.origin" class="mt-3 flex items-center justify-between gap-3 text-sm"><span>{{ partner.origin }} · {{ partner.active ? 'Freigegeben' : 'Gesperrt' }}</span><UButton variant="outline" size="xs" :disabled="busy" @click="savePartner(partner.origin, !partner.active)">{{ partner.active ? 'Sperren' : 'Freigeben' }}</UButton></div>
</section>
<form v-if="showCreate" class="grid gap-3 rounded-xl border border-gray-200 p-4 sm:grid-cols-2 dark:border-gray-700" @submit.prevent="createRoom">
<label class="text-sm">Projekt<select v-model="create.projectId" class="field"><option value="">Freier Partnerchat</option><option v-for="project in state.projects" :key="project.id" :value="String(project.id)">{{ project.name }}</option></select></label>
<label class="text-sm">Name des Unterraums<input v-model="create.name" required maxlength="160" class="field" placeholder="z. B. Abstimmung Gerüstbau" /></label>
<label class="text-sm sm:col-span-2">Beschreibung<input v-model="create.topic" maxlength="1000" class="field" /></label>
<p class="text-xs text-gray-500">Du nimmst als Ersteller am Chat teil. Weitere Personen werden ausdrücklich eingeladen.</p>
<div class="flex justify-end gap-2"><UButton variant="ghost" @click="showCreate = false">Abbrechen</UButton><UButton type="submit" :loading="busy">Erstellen</UButton></div>
</form>
<section v-if="state.invitations.length" class="space-y-2">
<h2 class="font-semibold">Einladungen</h2>
<div v-for="invitation in state.invitations" :key="invitation.id" class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-green-200 bg-green-50 p-3 text-gray-900">
<div><p class="font-medium">{{ invitation.name }}</p><p class="text-sm">{{ invitation.ownerName }} · {{ invitation.role === 'reader' ? 'Nur lesen' : 'Chatteilnahme' }}{{ invitation.overviewAllowed ? ' · Projektübersicht freigegeben' : '' }}</p></div>
<div class="flex gap-2"><UButton variant="outline" :disabled="busy" @click="respond(invitation.id, false)">Ablehnen</UButton><UButton :disabled="busy" @click="respond(invitation.id, true)">Annehmen</UButton></div>
</div>
</section>
<div class="grid items-start gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside class="space-y-2">
<h2 class="mb-3 font-semibold">Meine Unterräume</h2>
<p v-if="!visibleRooms.length" class="text-sm text-gray-500">Hier erscheinen deine Einladungen und freigegebenen Unterräume.</p>
<button v-for="item in visibleRooms" :key="item.id" class="w-full rounded-xl border p-3 text-left" :class="selected === item.id ? 'border-green-600 bg-green-50 text-gray-900' : 'border-gray-200 dark:border-gray-700'" @click="selected = item.id">
<span class="flex items-center justify-between gap-2"><span class="font-medium">{{ item.name }}</span><span v-if="item.unread" class="rounded-full bg-green-700 px-2 text-xs text-white">{{ item.unread }}</span></span>
<span class="mt-1 block text-xs text-gray-500">{{ item.ownerName || (item.projectId ? 'Projekt-Unterraum' : 'Partnerchat') }}{{ item.remote ? ' · Extern' : '' }}{{ item.status !== 'active' ? ` · ${item.status === 'blocked' ? 'Gesperrt' : 'Archiviert'}` : '' }}</span>
</button>
</aside>
<div class="min-w-0 space-y-4">
<div class="flex flex-wrap gap-2">
<UButton v-if="readable" :to="{ path: '/communication/chat', query: { room: `collaboration:${selected}` } }" variant="outline">Im Chat öffnen</UButton>
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
</div>
<section v-if="showManagement && selectedManaged" class="space-y-5 rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<form class="space-y-3" @submit.prevent="invitePerson">
<h2 class="font-semibold">Person in diesen Unterraum einladen</h2>
<label class="block text-sm">Teilnehmerart<select v-model="invite.kind" class="field"><option value="user">Eigene Mitarbeiter</option><option value="contact">FEDEO-Kontakt</option><option v-if="selectedManaged.projectId" value="guest">Gast ohne FEDEO-Konto</option></select></label>
<label v-if="invite.kind === 'user'" class="block text-sm">Person<select v-model="invite.userId" class="field" required><option value="">Bitte auswählen</option><option v-for="person in contacts" :key="person.id" :value="person.id">{{ person.name }}</option></select></label>
<template v-else-if="invite.kind === 'guest'"><label class="block text-sm">Name<input v-model="invite.name" required class="field" /></label><label class="block text-sm">E-Mail-Adresse<input v-model="invite.email" type="email" required class="field" /></label><label class="flex items-center gap-2 text-sm"><input v-model="invite.sendEmail" type="checkbox" /> Einladung per E-Mail senden</label></template>
<label v-else class="block text-sm">Persönliche FEDEO-Kontaktadresse<input v-model="invite.contact" type="url" required class="field" placeholder="Kontaktadresse der Person einfügen" /></label>
<div class="grid gap-3 sm:grid-cols-2"><label class="text-sm">Rechte<select v-model="invite.role" class="field"><option value="participant">Lesen und schreiben</option><option value="reader">Nur lesen</option></select></label><label class="text-sm">Zugang bis (optional)<input v-model="invite.expiresAt" type="date" class="field" /></label></div>
<label class="flex items-center gap-2 text-sm"><input v-model="invite.overviewAllowed" type="checkbox" /> Freigegebene Projektübersicht anzeigen</label>
<p class="text-xs text-gray-500">Die Einladung gilt nur für diesen Unterraum. Nachrichten sind ab dem Beitritt sichtbar. Gastzugänge gelten ohne gewähltes Ablaufdatum 30 Tage.</p>
<UButton type="submit" :loading="busy">Einladen</UButton>
</form>
<div v-if="inviteResult" role="status" class="space-y-2 rounded-lg bg-green-50 p-3 text-sm text-gray-900"><p>Einladung erstellt. {{ inviteResult.mailSent ? 'E-Mail wurde versendet.' : '' }}</p><template v-if="inviteResult.url"><p v-if="invite.sendEmail && !inviteResult.mailSent" class="text-amber-800">Die E-Mail konnte nicht versendet werden. Du kannst den Einladungslink kopieren.</p><UButton variant="outline" size="sm" @click="copy(inviteResult.url)">Einladungslink kopieren</UButton></template></div>
<div class="space-y-3"><h3 class="font-semibold">Teilnehmer</h3>
<div v-for="member in management.members" :key="member.id" class="space-y-2 rounded-lg border border-gray-200 p-3 text-sm dark:border-gray-700">
<p class="font-medium">{{ member.name }}{{ member.kind === 'guest' ? ' · Gast' : '' }}{{ member.status === 'revoked' ? ' · Entzogen' : '' }}</p>
<form v-if="member.status === 'active'" class="flex flex-wrap items-center gap-3" @submit.prevent="run(async () => { await $api(`/api/collaboration/rooms/${selected}/members/${member.id}`, { method: 'PATCH', body: { role: member.role, overviewAllowed: member.overviewAllowed } }); await loadManagement(); await refresh() })">
<label>Rolle<select v-model="member.role" class="field"><option value="participant">Lesen und schreiben</option><option value="reader">Nur lesen</option></select></label>
<label class="flex items-center gap-2"><input v-model="member.overviewAllowed" type="checkbox" /> Projektübersicht</label>
<UButton type="submit" variant="outline" size="xs" :disabled="busy">Rechte speichern</UButton>
<UButton color="error" variant="outline" size="xs" :disabled="busy" @click="revoke(member.id)">Zugang entziehen</UButton>
</form>
</div>
</div>
<div v-if="management.invitations.some(i => i.status === 'pending')" class="space-y-2"><h3 class="font-semibold">Offene Einladungen</h3><div v-for="item in management.invitations.filter(i => i.status === 'pending')" :key="item.id" class="flex items-center justify-between gap-2 text-sm"><span>{{ item.name }} · bis {{ new Date(item.expiresAt).toLocaleDateString('de-DE') }}<span v-if="item.delivery" class="block text-xs">{{ item.delivery.status === 'sent' ? 'Bei Partnerinstanz zugestellt' : item.delivery.status === 'failed' ? 'Zustellung fehlgeschlagen' : 'Wartet auf Zustellung' }}</span></span><UButton v-if="item.delivery?.status === 'failed'" variant="outline" size="xs" :disabled="busy" @click="run(async () => { await $api(`/api/collaboration/rooms/${selected}/invitations/${item.id}/retry`, { method: 'POST' }); await loadManagement() })">Erneut zustellen</UButton><UButton variant="outline" size="xs" :disabled="busy" @click="revoke(item.id, true)">Widerrufen</UButton></div></div>
<form class="space-y-3 border-t border-gray-200 pt-4 dark:border-gray-700" @submit.prevent="saveRoom">
<h3 class="font-semibold">Unterraum und freigegebene Informationen</h3>
<label class="block text-sm">Raumname<input v-model="details.name" required maxlength="160" class="field" /></label>
<label class="block text-sm">Beschreibung<input v-model="details.topic" maxlength="1000" class="field" /></label>
<label class="block text-sm">Status<select v-model="details.status" class="field"><option value="active">Aktiv</option><option value="archived">Archiviert – nur lesen</option><option value="blocked">Gesperrt – kein Zugriff</option></select></label>
<p class="text-xs text-gray-500">Die folgenden Angaben sehen ausschließlich Teilnehmer mit freigegebener Projektübersicht.</p>
<div class="grid gap-3 sm:grid-cols-2"><label class="text-sm">Externer Projekttitel<input v-model="details.overview.title" maxlength="160" class="field" /></label><label class="text-sm">Projektstatus<input v-model="details.overview.status" maxlength="160" class="field" /></label><label class="text-sm">Termin<input v-model="details.overview.date" maxlength="160" class="field" /></label></div>
<label class="block text-sm">Freigegebene Hinweise<textarea v-model="details.overview.description" maxlength="2000" rows="3" class="field" /></label>
<UButton type="submit" :loading="busy">Speichern</UButton>
</form>
</section>
<CollaborationRoom v-if="selected && readable" :key="selected" :room-id="selected" :api="$api" @changed="refresh" />
<p v-else-if="selected" class="rounded-xl border border-gray-200 p-6 text-sm text-gray-500 dark:border-gray-700">Du hast derzeit keinen Lesezugriff auf diesen Unterraum. Die Verwaltung ist davon unabhängig.</p>
</div>
</div>
</div>
</template>
<style scoped>
.field { display: block; width: 100%; margin-top: .25rem; border: 1px solid #9ca3af; border-radius: .5rem; padding: .55rem .7rem; background: transparent; }
</style>