Skip to content
374 changes: 160 additions & 214 deletions src/components/Editor/Resources/ResourceList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,238 +4,184 @@
-->

<template>
<div>
<span v-if="!(!resources.length && !suggestedRooms.length)" class="app-full-subtitle"> <MapMarker :size="20" /> {{ t('calendar', 'Resources') }}</span>

<ResourceListSearch
v-if="!isReadOnly && hasUserEmailAddress && resourceBookingEnabled"
:alreadyInvitedEmails="alreadyInvitedEmails"
:calendarObjectInstance="calendarObjectInstance"
@addResource="addResource" />

<div class="resource-list">
<ResourceListItem
v-for="resource in resources"
:key="resource.email"
:resource="resource"
:organizerDisplayName="organizerDisplayName"
<div class="resource-picker">
<span class="app-full-subtitle">
<MapMarker :size="20" />
{{ t('calendar', 'Rooms') }}
</span>

<NcButton
v-if="!props.isReadOnly && hasUserEmailAddress && resourceBookingEnabled"
class="resource-picker__browse"
wide
@click="showModal = true">
<template #icon>
<Plus :size="20" />
</template>
{{ t('calendar', 'Add') }}
</NcButton>

<!-- Rooms and resources already booked for this event -->
<div v-if="selectedRooms.length > 0" class="resource-picker__selected">
<ResourceRoomCard
v-for="room in selectedRooms"
:key="String(room.emailAddress)"
:room="room"
:isStatic="true"
:compact="true"
:removable="!props.isReadOnly && isViewedByOrganizer"
:isReadOnly="props.isReadOnly"
:isViewedByOrganizer="isViewedByOrganizer"
@removeResource="removeResource" />

<ResourceListItem
v-for="room in suggestedRooms"
:key="room.email + '-suggested'"
:resource="room"
:organizerDisplayName="organizerDisplayName"
:isSuggestion="true"
:isViewedByOrganizer="isViewedByOrganizer"
@addSuggestion="addResource" />
@remove="removeRoomByPrincipal" />
</div>

<RoomPickerModal
v-if="showModal"
:isReadOnly="props.isReadOnly"
:calendarObjectInstance="props.calendarObjectInstance"
@close="showModal = false" />
</div>
</template>

<script>
<script setup lang="ts">
import type { RoomPrincipal } from '../../../models/principal.ts'

import { loadState } from '@nextcloud/initial-state'
import { mapStores } from 'pinia'
import { t } from '@nextcloud/l10n'
import { NcButton } from '@nextcloud/vue'
import { computed, ref } from 'vue'
import MapMarker from 'vue-material-design-icons/MapMarker.vue'
import ResourceListItem from './ResourceListItem.vue'
import ResourceListSearch from './ResourceListSearch.vue'
import { advancedPrincipalPropertySearch } from '../../../services/caldavService.js'
import { checkResourceAvailability } from '../../../services/freeBusyService.js'
import Plus from 'vue-material-design-icons/Plus.vue'
import ResourceRoomCard from './ResourceRoomCard.vue'
import RoomPickerModal from './RoomPickerModal.vue'
import useCalendarObjectInstanceStore from '../../../store/calendarObjectInstance.js'
import usePrincipalsStore from '../../../store/principals.js'
import { organizerDisplayName, removeMailtoPrefix } from '../../../utils/attendee.js'
import logger from '../../../utils/logger.js'

export default {
name: 'ResourceList',
components: {
ResourceListItem,
ResourceListSearch,
MapMarker,
},

props: {
isReadOnly: {
type: Boolean,
required: true,
},

calendarObjectInstance: {
type: Object,
required: true,
},
},

data() {
return {
suggestedRooms: [],
}
},

computed: {
...mapStores(usePrincipalsStore, useCalendarObjectInstanceStore),
resources() {
return this.calendarObjectInstance.attendees.filter((attendee) => {
return ['ROOM', 'RESOURCE'].includes(attendee.attendeeProperty.userType)
})
},

attendees() {
return this.calendarObjectInstance.attendees.filter((attendee) => {
return !['RESOURCE', 'ROOM'].includes(attendee.attendeeProperty.userType)
})
},

noResourcesMessage() {
return this.$t('calendar', 'No rooms or resources yet')
},

isListEmpty() {
return this.resources.length === 0 && this.suggestedRooms.length === 0
},

alreadyInvitedEmails() {
return this.resources.map((attendee) => removeMailtoPrefix(attendee.uri))
},

organizerDisplayName() {
return organizerDisplayName(this.calendarObjectInstance.organizer)
},

hasUserEmailAddress() {
const emailAddress = this.principalsStore.getCurrentUserPrincipal?.emailAddress
return !!emailAddress
},

isViewedByOrganizer() {
if (!this.calendarObjectInstance.organizer) {
return true
}

const organizerEmail = removeMailtoPrefix(this.calendarObjectInstance.organizer.uri)
return organizerEmail === this.principalsStore.getCurrentUserPrincipalEmail
},

resourceBookingEnabled() {
return loadState('calendar', 'resource_booking_enabled')
},
},

watch: {
resources() {
if (this.isViewedByOrganizer) {
this.loadRoomSuggestions()
}
},
},

async mounted() {
if (this.isViewedByOrganizer) {
await this.loadRoomSuggestions()
import { getResourceAttendees, getRoomAttendees, removeMailtoPrefix } from '../../../utils/attendee.js'

interface Attendee {
uri: string
commonName: string | null
calendarUserType: string
attendeeProperty: { userType: string }
}

interface CalendarObjectInstanceProp {
startDate: Date
endDate: Date
attendees: Attendee[]
organizer: { uri: string } | null
eventComponent: { startDate: object, endDate: object }
}

const props = defineProps<{
isReadOnly: boolean
calendarObjectInstance: CalendarObjectInstanceProp
}>()

const principalsStore = usePrincipalsStore()
const calendarObjectInstanceStore = useCalendarObjectInstanceStore()

const resourceBookingEnabled = loadState<boolean>('calendar', 'resource_booking_enabled', false)

const showModal = ref(false)

// Computed
const bookedAttendees = computed<Attendee[]>(() => {
return [
...getRoomAttendees(props.calendarObjectInstance.attendees),
...getResourceAttendees(props.calendarObjectInstance.attendees),
]
})

/**
* The principals matching the rooms and resources booked for this event.
*
* Rooms and resources are filtered out of the attendee list, so this is the only
* place in the editor showing what is currently reserved. No availability request
* is needed here: anything booked is rendered as reserved either way.
*/
const selectedRooms = computed<RoomPrincipal[]>(() => {
const principals = [
...(principalsStore.getRoomPrincipals || []),
...(principalsStore.getResourcePrincipals || []),
]

return bookedAttendees.value.map((attendee) => {
const email = removeMailtoPrefix(attendee.uri)
const principal = principals.find((p: RoomPrincipal) => p.emailAddress === email)
if (principal) {
return { ...principal, isAvailable: true } as RoomPrincipal
}
},

methods: {
addResource({ commonName, email, calendarUserType, language, timezoneId, roomAddress }) {
this.calendarObjectInstanceStore.addAttendee({
calendarObjectInstance: this.calendarObjectInstance,
commonName,
uri: email,
calendarUserType,
participationStatus: 'NEEDS-ACTION',
role: 'REQ-PARTICIPANT',
rsvp: true,
language,
timezoneId,
organizer: this.principalsStore.getCurrentUserPrincipal,
})
this.updateLocation(roomAddress)
},

removeResource(resource) {
this.calendarObjectInstanceStore.removeAttendee({
calendarObjectInstance: this.calendarObjectInstance,
attendee: resource,
})
},

async loadRoomSuggestions() {
if (!this.resourceBookingEnabled) {
return
}

if (this.resources.length > 0) {
this.suggestedRooms = []
return
}

try {
logger.info('fetching suggestions for ' + this.attendees.length + ' attendees')
const query = {
capacity: this.attendees.length,
}
const results = (await advancedPrincipalPropertySearch(query)).map((principal) => {
return {
commonName: principal.displayname,
email: principal.email,
calendarUserType: principal.calendarUserType,
displayName: principal.displayname ?? principal.email,
isAvailable: true,
roomAddress: principal.roomAddress,
uri: principal.email,
organizer: this.principalsStore.getCurrentUserPrincipal,
}
})

await checkResourceAvailability(
results,
this.principalsStore.getCurrentUserPrincipalEmail,
this.calendarObjectInstance.eventComponent.startDate,
this.calendarObjectInstance.eventComponent.endDate,
)
logger.debug('availability of room suggestions fetched', { results })

// Take the first three available options
this.suggestedRooms = results.filter((room) => room.isAvailable).slice(0, 3)
} catch (error) {
logger.error('Could not find resources', { error })
this.suggestedRooms = []
}
},

/**
* Apply the location of the first resource to the event.
* Has no effect if the location is already set or there are multiple resource.
*
* @param {string} location The location to apply
*/
updateLocation(location) {
if (this.calendarObjectInstance.location || this.calendarObjectInstance.eventComponent.location) {
return
}

if (this.resources.length !== 1) {
return
}

this.calendarObjectInstanceStore.changeLocation({
calendarObjectInstance: this.calendarObjectInstance,
location,
})
},
},

// The principal is unknown (removed room, or not readable by this user)
return {
id: email,
displayname: attendee.commonName || email,
emailAddress: email,
calendarUserType: attendee.calendarUserType || 'ROOM',
isAvailable: true,
roomSeatingCapacity: null,
roomType: null,
roomAddress: null,
roomFeatures: null,
roomNumber: null,
roomFloor: null,
roomBuildingName: null,
roomBuildingAddress: null,
} as RoomPrincipal
})
})

const hasUserEmailAddress = computed<boolean>(() => {
const emailAddress = principalsStore.getCurrentUserPrincipal?.emailAddress
return !!emailAddress
})

const isViewedByOrganizer = computed<boolean>(() => {
if (!props.calendarObjectInstance.organizer) {
return true
}
const organizerEmail = removeMailtoPrefix(props.calendarObjectInstance.organizer.uri)
return organizerEmail === principalsStore.getCurrentUserPrincipalEmail
})

// Methods
/**
* Remove the attendee matching a booked room or resource
*
* @param room Principal to remove from the event
*/
function removeRoomByPrincipal(room: RoomPrincipal): void {
const attendee = bookedAttendees.value.find((a) => removeMailtoPrefix(a.uri) === room.emailAddress)
if (!attendee) {
return
}

calendarObjectInstanceStore.removeAttendee({
calendarObjectInstance: props.calendarObjectInstance,
attendee,
})
}
</script>

<style lang="scss" scoped>
.resource-list {
margin-top: calc(var(--default-grid-baseline) * 4);
.resource-picker {
display: flex;
flex-direction: column;
gap: calc(var(--default-grid-baseline) * 2);

&__selected {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
width: 100%;
}
}

.app-full-subtitle {
font-size: calc(var(--default-font-size) * 1.2);
display: flex;
gap: calc(var(--default-grid-baseline) * 4);
align-items: center;
gap: calc(var(--default-grid-baseline) * 2);
}
</style>
Loading