Event Grouping & Bulk Actions
Usage
theme: 'ios' You can turn auto theming on and let it decide based on the platform.
Usage
themeVariant: 'light' You can turn auto dark mode on based on system settings.
Usage
clickToCreate: true,
// click to create is on dragToCreate: true,
// drag to create is on dragToMove: true,
// reordering is on dragToResize: true,
// resizing is on eventDelete: true
// deleting is on Fine-tune drag & drop with these options, which are false by default.
Usage
locale: localeEn Selecting the locale loads the localized settings and the translated UI text.
Event Grouping & Bulk Actions
When managing a large volume of events across multiple clients and time periods, handling items one by one quickly becomes inefficient. Users often need to move entire sets of related tasks together, not individual events. To support this, grouping and bulk interaction are essential.
The starting point is being able to look at the same dataset through different lenses. The demo supports switching between an assignee view, where each row represents a person responsible for the work, and a work type view, where each row represents a task category. A segmented control in the header allows adjusting the time granularity of the timeline while keeping a full year in view, enabling users to switch between quarterly, monthly, or weekly breakdowns. The view selector, grouping toggle, and segmented control are all rendered via the header slot, keeping the controls integrated into the timeline header. The resource slot adapts the resource column accordingly, displaying employee avatars and job titles in assignee view, and work type color indicators in type view.
Once the right perspective is selected, individual events are not rendered one by one. Using the timelineEvent slot, related tasks are automatically grouped into consolidated blocks per client and per quarter. These blocks can be expanded to reveal the individual tasks inside, or collapsed to keep the timeline readable at scale.
With grouping in place, bulk interaction becomes natural. An entire grouped block can be dragged along the timeline, moving all underlying events simultaneously. The system calculates the time delta and applies it uniformly to every event in the group. At the same time, individual tasks within a group remain accessible and can be edited through the edit button, which opens a range picker to update the event dates. Because both views are projections of the same event store, a change made in one view is immediately reflected in the other.
Demo

Code
View code on GitHub
<script setup>
import {
formatDate,
MbscCalendarNav,
MbscCalendarNext,
MbscCalendarPrev,
MbscCalendarToday,
MbscCheckbox,
MbscConfirm,
MbscDatepicker,
MbscEventcalendar,
MbscSegmented,
MbscSegmentedGroup,
MbscSelect,
MbscToast,
setOptions
} from '@mobiscroll/vue'
import { computed, ref } from 'vue'
setOptions({
locale: ,
theme: ' ',
themeVariant: ' '
})
const assigneeResources = [
{
id: 1,
name: 'Sarah Johnson',
title: 'Senior HVAC Technician',
color: '#4a7c9e',
img: 'https://img.mobiscroll.com/demos/f1.png'
},
{
id: 2,
name: 'Mike Chen',
title: 'Lead Facilities Engineer',
color: '#7fa650',
img: 'https://img.mobiscroll.com/demos/m1.png'
},
{
id: 3,
name: 'Emily Rodriguez',
title: 'Electrical Systems Specialist',
color: '#c76b8a',
img: 'https://img.mobiscroll.com/demos/f2.png'
},
{
id: 4,
name: 'James Wilson',
title: 'Master Electrician',
color: '#4a9dad',
img: 'https://img.mobiscroll.com/demos/m2.png'
},
{
id: 5,
name: 'Lisa Anderson',
title: 'Building Automation Technician',
color: '#d4a056',
img: 'https://img.mobiscroll.com/demos/f3.png'
},
{
id: 6,
name: 'David Kim',
title: 'HVAC Project Manager',
color: '#5a9d76',
img: 'https://img.mobiscroll.com/demos/m3.png'
}
]
const typeResources = [
{
id: 'installation',
name: 'installation',
color: '#3b5998'
},
{
id: 'maintenance',
name: 'maintenance',
color: '#2d7a4f'
},
{
id: 'repair',
name: 'repair',
color: '#b8621b'
},
{
id: 'inspection',
name: 'inspection',
color: '#6b4fa3'
},
{
id: 'upgrade',
name: 'upgrade',
color: '#a03a3a'
}
]
const selectData = [
{
text: 'View by Assignee',
value: 'assignee'
},
{
text: 'View by Type',
value: 'type'
}
]
const defaultEvents = [
{
id: 1,
title: 'Office Tower HVAC System Overhaul',
start: '2026-01-05T00:00',
end: '2026-03-15T00:00',
resource: 1,
type: 'upgrade',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 2,
title: 'Executive Floor Climate Control Installation',
start: '2026-01-10T00:00',
end: '2026-03-20T00:00',
resource: 1,
type: 'installation',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 3,
title: 'Headquarters Heating System Maintenance',
start: '2026-02-01T00:00',
end: '2026-03-31T00:00',
resource: 1,
type: 'maintenance',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 4,
title: 'Building Infrastructure Assessment',
start: '2026-01-15T00:00',
end: '2026-03-25T00:00',
resource: 2,
type: 'inspection',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 5,
title: 'Campus Facilities Modernization',
start: '2026-02-05T00:00',
end: '2026-03-31T00:00',
resource: 2,
type: 'upgrade',
clientGroup: 'Enterprise Solutions Group'
},
// Showing partial data. Download full source.
]
const zoomLevel = ref('month')
const myView = computed(() => ({
timeline: {
type: 'year',
resolutionHorizontal: zoomLevel.value,
eventHeight: 'variable'
}
}))
// Reactive state
const rawEvents = ref([...defaultEvents])
const displayEvents = ref([])
const groupedEvents = ref([])
const myResources = ref(assigneeResources)
const groupBy = ref('assignee')
const groupByClientQuarter = ref(false)
const isToastOpen = ref(false)
const toastMessage = ref('')
const calendarRef = ref(null)
const isConfirmOpen = ref(false)
const confirmMessage = ref('')
let confirmCallback = null
const editingEventId = ref(null)
const editEventTitle = ref('')
const editEventDateRange = ref([])
const isDatePickerOpen = ref(false)
function groupEventsByClientQuarter(events) {
const groups = new Map()
const result = []
// Save old collapsed states
const oldCollapsedStates = {}
groupedEvents.value.forEach((ge) => {
const stateKey = `${ge.resource}-${ge.clientGroup}-${ge.year}-${ge.period}`
oldCollapsedStates[stateKey] = ge.collapsed
})
// Group events
events.forEach((event) => {
const resourceId = groupBy.value === 'assignee' ? event.resource : event.type
const eventStart = new Date(event.start)
const month = eventStart.getMonth()
const year = eventStart.getFullYear()
const period = Math.floor(month / 3)
const groupKey = `${resourceId}-${event.clientGroup}-${year}-${period}`
if (!groups.has(groupKey)) {
groups.set(groupKey, {
resource: resourceId,
clientGroup: event.clientGroup,
year,
period,
events: []
})
}
groups.get(groupKey).events.push(event)
})
// Create grouped events
groups.forEach((groupData, groupKey) => {
const periodEvents = [...groupData.events].sort((a, b) =>
a.start < b.start ? -1 : a.start > b.start ? 1 : 0
)
const resourceList = groupBy.value === 'assignee' ? assigneeResources : typeResources
const color = resourceList.find((r) => r.id === groupData.resource)?.color
const eventIds = periodEvents.map((e) => e.id).join('-')
const earliestStart = periodEvents[0].start
const latestEnd = periodEvents.reduce(
(latest, e) => (e.end > latest ? e.end : latest),
periodEvents[0].end
)
const newId = `group-${groupKey}-${eventIds}`
const stateKey = `${groupData.resource}-${groupData.clientGroup}-${groupData.year}-${groupData.period}`
result.push({
id: newId,
title: groupData.clientGroup,
resource: groupData.resource,
clientGroup: groupData.clientGroup,
year: groupData.year,
period: groupData.period,
start: earliestStart,
end: latestEnd,
color,
count: periodEvents.length,
originalEvents: periodEvents,
collapsed: stateKey in oldCollapsedStates ? oldCollapsedStates[stateKey] : true
})
})
return result
}
function prepareEventsForDisplay(events) {
return events.map((event) => ({
id: event.id,
title: event.title,
start: event.start,
end: event.end,
resource: groupBy.value === 'assignee' ? event.resource : event.type,
type: event.type,
assignee: event.resource,
clientGroup: event.clientGroup,
color: '#f8f9fa'
}))
}
function updateView() {
const currentResources = groupBy.value === 'assignee' ? assigneeResources : typeResources
if (groupByClientQuarter.value) {
groupedEvents.value = groupEventsByClientQuarter(rawEvents.value)
displayEvents.value = groupedEvents.value
} else {
groupedEvents.value = []
displayEvents.value = prepareEventsForDisplay(rawEvents.value)
}
myResources.value = currentResources
}
function handleEventUpdated(args) {
const updatedEvent = args.event
const oldEvent = args.oldEvent
const newStart = new Date(updatedEvent.start).getTime()
const newEnd = new Date(updatedEvent.end).getTime()
if (groupByClientQuarter.value) {
const oldStart = new Date(oldEvent.start).getTime()
const oldEnd = new Date(oldEvent.end).getTime()
const startDelta = newStart - oldStart
const endDelta = newEnd - oldEnd
if (startDelta === 0 && endDelta === 0) return
const movedGroupedEvent = groupedEvents.value.find((ge) => ge.id === oldEvent.id)
if (!movedGroupedEvent) return
const {
clientGroup: clientGroupName,
resource: resourceId,
collapsed: wasCollapsed
} = movedGroupedEvent
const isMove = startDelta === endDelta
let eventsToUpdate
if (isMove) {
eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent) => ({
...originalEvent,
start: new Date(new Date(originalEvent.start).getTime() + startDelta),
end: new Date(new Date(originalEvent.end).getTime() + startDelta)
}))
} else {
const oldGroupDuration = oldEnd - oldStart
const newGroupDuration = newEnd - newStart
eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent) => {
const evStart = new Date(originalEvent.start).getTime()
const evEnd = new Date(originalEvent.end).getTime()
const relativeStart = oldGroupDuration > 0 ? (evStart - oldStart) / oldGroupDuration : 0
const relativeEnd = oldGroupDuration > 0 ? (evEnd - oldStart) / oldGroupDuration : 1
const mappedStart = newStart + relativeStart * newGroupDuration
const mappedEnd = newStart + relativeEnd * newGroupDuration
const childDurationDays = (mappedEnd - mappedStart) / (1000 * 60 * 60 * 24)
return {
...originalEvent,
start: new Date(mappedStart),
end: new Date(childDurationDays < 1 ? mappedStart + 1000 * 60 * 60 * 24 : mappedEnd)
}
})
}
const updatedMap = new Map(eventsToUpdate.map((e) => [e.id, e]))
rawEvents.value = rawEvents.value.map((e) => (updatedMap.has(e.id) ? updatedMap.get(e.id) : e))
updateView()
const newYear = new Date(eventsToUpdate[0].start).getFullYear()
const newPeriod = Math.floor(new Date(eventsToUpdate[0].start).getMonth() / 3)
const newGroupedEvent = groupedEvents.value.find(
(ge) =>
ge.resource === resourceId &&
ge.clientGroup === clientGroupName &&
ge.year === newYear &&
ge.period === newPeriod
)
if (newGroupedEvent) {
newGroupedEvent.collapsed = wasCollapsed
displayEvents.value = [...groupedEvents.value]
}
const actionLabel = isMove ? 'moved' : 'resized'
toastMessage.value = `${eventsToUpdate.length} event(s) for ${clientGroupName} have been ${actionLabel}.`
isToastOpen.value = true
} else {
const oldResource = oldEvent.resource
const newResource = updatedEvent.resource
const resourceChanged = oldResource !== newResource
rawEvents.value = rawEvents.value.map((e) => {
if (e.id !== updatedEvent.id) return e
const update = { start: updatedEvent.start, end: updatedEvent.end }
if (resourceChanged) {
if (groupBy.value === 'assignee') update.resource = newResource
else update.type = String(newResource)
}
return { ...e, ...update }
})
if (resourceChanged) {
let fromName
let toName
if (groupBy.value === 'assignee') {
const fromRes = assigneeResources.find((r) => r.id === oldResource)
const toRes = assigneeResources.find((r) => r.id === newResource)
fromName = fromRes ? fromRes.name : String(oldResource)
toName = toRes ? toRes.name : String(newResource)
} else {
fromName = String(oldResource)
toName = String(newResource)
}
toastMessage.value = `"${updatedEvent.title}" moved from ${fromName} to ${toName}.`
isToastOpen.value = true
}
}
}
function toggleGroupCollapse(groupEvent) {
groupedEvents.value = groupedEvents.value.map((ge) =>
ge.id === groupEvent.id ? { ...ge, collapsed: !ge.collapsed } : ge
)
displayEvents.value = [...groupedEvents.value]
// Refresh calendar to recalculate row heights
setTimeout(() => {
calendarRef.value?.instance?.refresh()
}, 200)
}
function getUniqueItems(originalEvents) {
const uniqueItems = {}
originalEvents.forEach((ev) => {
if (groupBy.value === 'assignee') {
const typeObj = typeResources.find((r) => r.id === ev.type)
if (typeObj) uniqueItems[typeObj.id] = typeObj
} else {
const emp = assigneeResources.find((r) => r.id === ev.resource)
if (emp) uniqueItems[emp.id] = emp
}
})
return {
count: Object.keys(uniqueItems).length,
label: groupBy.value === 'assignee' ? 'type' : 'employee'
}
}
function getDetailInfo(event) {
let detailText = ''
let typeDotColor = undefined
let avatarUrl = undefined
if (groupBy.value === 'assignee') {
const typeObj = typeResources.find((t) => t.id === event.type)
if (typeObj) {
detailText = typeObj.name
typeDotColor = typeObj.color
}
} else {
const employee = assigneeResources.find((r) => r.id === event.assignee)
if (employee) {
detailText = employee.name
avatarUrl = employee.img
}
}
return { detailText, typeDotColor, avatarUrl }
}
function formatEventDate(date) {
return formatDate('DD MMM', new Date(date))
}
function getEventMarginLeft(ev, group) {
const groupStart = new Date(group.start).getTime()
const groupEnd = new Date(group.end).getTime()
const groupDuration = groupEnd - groupStart
const evStart = new Date(ev.start).getTime()
return groupDuration > 0 ? ((evStart - groupStart) / groupDuration) * 100 + '%' : '0%'
}
function getEventWidth(ev, group) {
const groupStart = new Date(group.start).getTime()
const groupEnd = new Date(group.end).getTime()
const groupDuration = groupEnd - groupStart
const evStart = new Date(ev.start).getTime()
const evEnd = new Date(ev.end).getTime()
return groupDuration > 0 ? ((evEnd - evStart) / groupDuration) * 100 + '%' : '100%'
}
function handleEditEvent(ev) {
editingEventId.value = ev.id ?? null
editEventTitle.value = ev.title ?? ''
editEventDateRange.value = [new Date(ev.start), new Date(ev.end)]
isDatePickerOpen.value = true
}
function handleEditDateChange(args) {
const dates = args.value
const startVal = dates && dates[0]
const endVal = dates && dates[1]
if (editingEventId.value === null || !startVal || !endVal) return
const oldEvent = rawEvents.value.find((e) => e.id === editingEventId.value)
if (!oldEvent) return
const oldQuarter = Math.floor(new Date(oldEvent.start).getMonth() / 3)
const newQuarter = Math.floor(new Date(startVal).getMonth() / 3)
const oldYear = new Date(oldEvent.start).getFullYear()
const newYear = new Date(startVal).getFullYear()
const quarterChanged =
groupByClientQuarter.value && (oldQuarter !== newQuarter || oldYear !== newYear)
const applyUpdate = () => {
rawEvents.value = rawEvents.value.map((e) =>
e.id === editingEventId.value ? { ...e, start: startVal, end: endVal } : e
)
updateView()
}
if (quarterChanged) {
const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4']
const fromLabel = `${quarterNames[oldQuarter]} ${oldYear}`
const toLabel = `${quarterNames[newQuarter]} ${newYear}`
confirmCallback = () => {
applyUpdate()
toastMessage.value = `"${oldEvent.title}" moved to ${toLabel}.`
isToastOpen.value = true
}
confirmMessage.value = `"${oldEvent.title}" will move from ${fromLabel} to ${toLabel}. Do you want to continue?`
isConfirmOpen.value = true
} else {
applyUpdate()
toastMessage.value = `"${oldEvent.title}" dates updated.`
isToastOpen.value = true
}
}
function handleConfirmClose(result) {
isConfirmOpen.value = false
if (result && confirmCallback) {
confirmCallback()
confirmCallback = null
}
}
// Initialize view on mount
updateView()
</script>
<template>
<MbscEventcalendar
ref="calendarRef"
cssClass="mds-event-group-calendar"
:data="displayEvents"
:view="myView"
:resources="myResources"
:dragToMove="true"
:dragToResize="true"
:dragToCreate="false"
:clickToCreate="false"
:dragBetweenResources="!groupByClientQuarter"
@event-updated="handleEventUpdated"
>
<template #header>
<MbscCalendarNav />
<div class="mbsc-flex mbsc-flex-1-0 mbsc-justify-content-end mds-event-group-header-controls">
<MbscCheckbox
v-model="groupByClientQuarter"
@change="updateView"
theme="material"
label="Group by Client/Quarter"
/>
<MbscSelect
inputStyle="box"
display="anchored"
:touchUi="false"
:data="selectData"
v-model="groupBy"
@change="updateView"
/>
</div>
<MbscSegmentedGroup v-model="zoomLevel">
<MbscSegmented value="quarter">Quarterly</MbscSegmented>
<MbscSegmented value="month">Monthly</MbscSegmented>
<MbscSegmented value="week">Weekly</MbscSegmented>
</MbscSegmentedGroup>
<MbscCalendarPrev />
<MbscCalendarToday />
<MbscCalendarNext />
</template>
<template #resource="resource">
<div v-if="groupBy === 'assignee'" class="mbsc-flex">
<img :alt="resource.name" class="mds-event-group-avatar" :src="resource.img" />
<div class="mds-event-group-cont">
<div class="mds-event-group-name">{{ resource.name }}</div>
<div class="mds-event-group-title">{{ resource.title }}</div>
</div>
</div>
<div v-else class="mbsc-flex mds-event-group-type-resource">
<div class="mds-event-group-type-badge" :style="{ backgroundColor: resource.color }"></div>
<div class="mds-event-group-type-name">{{ resource.name }}</div>
</div>
</template>
<template #timelineEvent="event">
<!-- Grouped Event Template -->
<div
v-if="groupByClientQuarter"
class="mbsc-flex mds-event-group-task mds-event-group-task-client"
:class="{ expanded: !event.original.collapsed }"
:style="{ borderLeftColor: event.original.color }"
>
<div class="mbsc-flex mds-event-group-content">
<div class="mds-event-group-title-text mds-event-group-text-truncate">
{{ event.original.clientGroup }}
</div>
<div class="mbsc-flex mds-event-group-right">
<div class="mbsc-flex mds-event-group-meta">
<div class="mds-event-group-date-range">
{{ formatEventDate(event.original.start) }} -
{{ formatEventDate(event.original.end) }}
</div>
<div class="mds-event-group-count">
{{ event.original.count }} task{{ event.original.count > 1 ? 's' : '' }},
{{ getUniqueItems(event.original.originalEvents).count }}
{{ getUniqueItems(event.original.originalEvents).label
}}{{ getUniqueItems(event.original.originalEvents).count > 1 ? 's' : '' }}
</div>
</div>
<div
class="mbsc-flex mds-event-group-icon mbsc-icon mbsc-font-icon mbsc-icon-material-keyboard-arrow-down"
@click.stop="toggleGroupCollapse(event.original)"
></div>
</div>
</div>
<div class="mds-event-group-events">
<div class="mds-event-group-events-inner">
<div
v-for="ev in event.original.originalEvents"
v-show="!event.original.collapsed"
:key="ev.id"
class="mds-event-group-original-event"
:title="`${ev.title}, ${formatEventDate(ev.start)} - ${formatEventDate(ev.end)}`"
:style="{
marginLeft: getEventMarginLeft(ev, event.original),
width: getEventWidth(ev, event.original)
}"
>
<div class="mbsc-flex mds-event-group-event-content">
<div class="mds-event-group-event-title mds-event-group-text-truncate">
{{ ev.title }}
</div>
<div class="mbsc-flex mds-event-group-event-right">
<div class="mds-event-group-event-date mds-event-group-text-truncate">
{{ formatEventDate(ev.start) }} - {{ formatEventDate(ev.end) }}
</div>
<div class="mbsc-flex mds-event-group-event-detail">
<img
v-if="getDetailInfo(ev).avatarUrl"
:src="getDetailInfo(ev).avatarUrl"
:alt="getDetailInfo(ev).detailText"
class="mds-event-group-event-avatar"
/>
<span
v-if="getDetailInfo(ev).typeDotColor"
class="mds-event-group-type-dot"
:style="{ backgroundColor: getDetailInfo(ev).typeDotColor }"
></span>
<div class="mds-event-group-event-info mds-event-group-text-truncate">
{{ getDetailInfo(ev).detailText }}
</div>
</div>
</div>
<div
class="mds-event-group-edit-btn mbsc-flex mbsc-icon mbsc-font-icon mbsc-icon-pencil"
@click.stop="handleEditEvent(ev)"
></div>
</div>
</div>
</div>
</div>
</div>
<!-- Simple Event Template -->
<div v-else class="mbsc-flex mds-event-simple">
<div class="mds-event-simple-title">
<div class="mds-event-simple-title-inner mds-event-group-text-truncate">
{{ event.original.title }}
</div>
</div>
<div class="mbsc-flex mds-event-simple-right">
<div class="mds-event-simple-date">
{{ formatEventDate(event.original.start) }} - {{ formatEventDate(event.original.end) }}
</div>
<div
v-if="getDetailInfo(event.original).detailText"
class="mbsc-flex mds-event-simple-subtitle-wrapper"
>
<img
v-if="getDetailInfo(event.original).avatarUrl"
:src="getDetailInfo(event.original).avatarUrl"
:alt="getDetailInfo(event.original).detailText"
class="mds-event-simple-avatar"
/>
<span
v-if="getDetailInfo(event.original).typeDotColor"
class="mds-event-simple-type-dot"
:style="{ backgroundColor: getDetailInfo(event.original).typeDotColor }"
></span>
<div
class="mbsc-flex mds-event-simple-subtitle"
:style="{ marginLeft: groupBy === 'type' ? '25px' : '' }"
>
{{ getDetailInfo(event.original).detailText }}
</div>
</div>
</div>
</div>
</template>
</MbscEventcalendar>
<MbscToast :message="toastMessage" :isOpen="isToastOpen" @close="isToastOpen = false" />
<MbscDatepicker
:controls="['calendar']"
select="range"
display="center"
:touchUi="false"
:showRangeLabels="false"
:headerText="editEventTitle"
:value="editEventDateRange"
:isOpen="isDatePickerOpen"
@close="isDatePickerOpen = false"
@change="handleEditDateChange"
/>
<MbscConfirm
:isOpen="isConfirmOpen"
title="Move to different group"
:message="confirmMessage"
okText="Move"
cancelText="Cancel"
@close="handleConfirmClose"
/>
</template>
<style>
.mds-event-group-calendar .mbsc-timeline-row {
height: 140px;
}
.mds-event-group-calendar .mbsc-timeline-resource-col {
width: 240px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-textfield-wrapper-box {
margin: 10px 20px;
}
.mds-event-group-calendar .mbsc-timeline-resource-title {
text-transform: capitalize;
}
.mds-event-group-avatar {
width: 40px;
height: 40px;
}
.mds-event-group-cont {
padding: 0 7px;
}
.mds-event-group-name {
font-size: 14px;
line-height: 24px;
}
.mds-event-group-title {
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.mds-event-group-type-resource {
align-items: center;
padding: 8px 0;
}
.mds-event-group-type-badge {
width: 12px;
height: 12px;
border-radius: 50%;
margin: 0 10px;
}
.mds-event-group-type-name {
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.mds-event-group-calendar .mbsc-schedule-event {
min-width: 72px;
}
.mds-event-group-calendar .mbsc-schedule-event:has(.mds-event-group-task-client) {
min-width: 120px;
}
.mds-event-group-task-client {
background-color: #ddd;
border-left: 4px solid;
border-radius: 0 8px 8px 0;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.08);
flex-direction: column;
container-type: inline-size;
min-width: 120px;
}
.mds-event-group-content {
justify-content: space-between;
align-items: center;
padding: 10px 14px;
height: 42px;
box-sizing: border-box;
gap: 8px;
}
.mds-event-group-title-text {
font-size: 14px;
font-weight: 600;
color: #1e293b;
line-height: 20px;
min-width: 0;
position: sticky;
left: 250px;
}
.mds-event-group-right {
align-items: center;
z-index: 1;
background-color: #ddd;
}
.mds-event-group-meta {
flex-direction: column;
align-items: flex-end;
flex: 1;
margin: 0 12px;
}
.mds-event-group-date-range {
color: #575757;
font-size: 11px;
margin-bottom: 2px;
}
.mds-event-group-count {
font-size: 11px;
white-space: nowrap;
}
.mds-event-group-icon {
font-size: 20px;
cursor: pointer;
transition: transform 0.2s ease;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
transform: rotate(0deg);
}
.mds-event-group-task.expanded .mds-event-group-icon {
transform: rotate(180deg);
}
.mds-event-group-events {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.mds-event-group-events-inner {
overflow: hidden;
padding: 0 14px 4px;
transition: padding 0.2s ease;
}
.mds-event-group-task.expanded .mds-event-group-events {
grid-template-rows: 1fr;
}
.mds-event-group-original-event {
background: #f1f1f1;
border-radius: 6px;
margin-bottom: 6px;
padding: 6px 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
box-sizing: border-box;
min-height: 38px;
min-width: 100px;
overflow: hidden;
container-type: inline-size;
}
@container (max-width: 180px) {
.mds-event-group-meta {
display: none;
}
}
@container (max-width: 130px) {
.mds-event-group-event-right {
display: none;
}
.mds-event-simple-right {
display: none;
}
}
.mds-event-group-event-content {
justify-content: space-between;
align-items: center;
line-height: 14px;
}
.mds-event-group-event-title {
font-weight: 600;
color: #1e293b;
font-size: 13px;
line-height: 18px;
flex: 1;
min-width: 30px;
margin-right: 8px;
}
.mds-event-group-text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mds-event-group-event-right {
flex-direction: column;
align-items: flex-end;
min-width: 0;
}
.mds-event-group-event-date {
font-size: 11px;
color: #94a3b8;
text-align: right;
margin-bottom: 2px;
max-width: 100%;
}
.mds-event-group-event-detail {
align-items: center;
justify-content: flex-end;
overflow: hidden;
max-width: 100%;
}
.mds-event-group-event-info {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: capitalize;
}
.mds-event-group-event-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 6px;
}
.mds-event-group-type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.mds-event-group-edit-btn {
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
color: #94a3b8;
padding: 4px;
margin-left: 8px;
border-radius: 4px;
transition:
color 0.15s ease,
background-color 0.15s ease;
flex-shrink: 0;
}
.mds-event-group-edit-btn:hover {
color: #1e293b;
background-color: rgba(0, 0, 0, 0.06);
}
.mds-event-simple {
padding: 10px 12px;
border-radius: 6px;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.15),
0 1px 2px rgba(0, 0, 0, 0.1);
justify-content: space-between;
align-items: center;
height: 42px;
box-sizing: border-box;
color: #2c2c2c;
background-color: #ddd;
container-type: inline-size;
gap: 8px;
}
.mds-event-simple-title {
font-size: 13px;
font-weight: 600;
position: sticky;
left: 250px;
min-width: 0;
}
.mds-event-simple-title-inner {
display: inline-block;
max-width: 100%;
}
.mds-event-simple-right {
flex-direction: column;
z-index: 1;
background-color: #ddd;
}
.mds-event-simple-date {
font-size: 11px;
opacity: 0.85;
white-space: nowrap;
text-align: right;
margin-bottom: 2px;
}
.mds-event-simple-subtitle-wrapper {
position: relative;
align-items: center;
justify-content: flex-end;
}
.mds-event-simple-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 6px;
position: absolute;
left: 0;
}
.mds-event-simple-type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin: 0 6px;
}
.mds-event-simple-subtitle {
font-size: 11px;
font-weight: 700;
text-transform: capitalize;
white-space: nowrap;
}
.mds-event-group-header-controls {
align-items: center;
}
.mds-event-group-select .mbsc-textfield {
width: 210px;
max-height: 34px;
}
.mds-event-group-select .mbsc-ios.mbsc-select-icon {
font-size: 13px;
top: 8px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-segmented-button {
padding: 1px 12px;
}
</style> <script setup lang="ts">
import {
formatDate,
MbscCalendarNav,
MbscCalendarNext,
MbscCalendarPrev,
MbscCalendarToday,
MbscCheckbox,
MbscConfirm,
MbscDatepicker,
MbscEventcalendar,
MbscSegmented,
MbscSegmentedGroup,
MbscSelect,
MbscToast,
setOptions
} from '@mobiscroll/vue'
import type {
MbscCalendarEvent,
MbscEventcalendarView,
MbscEventUpdateEvent,
MbscResource,
MbscSelectData
} from '@mobiscroll/vue'
import { computed, ref } from 'vue'
setOptions({
locale: ,
theme: ' ',
themeVariant: ' '
})
const assigneeResources: MbscResource[] = [
{
id: 1,
name: 'Sarah Johnson',
title: 'Senior HVAC Technician',
color: '#4a7c9e',
img: 'https://img.mobiscroll.com/demos/f1.png'
},
{
id: 2,
name: 'Mike Chen',
title: 'Lead Facilities Engineer',
color: '#7fa650',
img: 'https://img.mobiscroll.com/demos/m1.png'
},
{
id: 3,
name: 'Emily Rodriguez',
title: 'Electrical Systems Specialist',
color: '#c76b8a',
img: 'https://img.mobiscroll.com/demos/f2.png'
},
{
id: 4,
name: 'James Wilson',
title: 'Master Electrician',
color: '#4a9dad',
img: 'https://img.mobiscroll.com/demos/m2.png'
},
{
id: 5,
name: 'Lisa Anderson',
title: 'Building Automation Technician',
color: '#d4a056',
img: 'https://img.mobiscroll.com/demos/f3.png'
},
{
id: 6,
name: 'David Kim',
title: 'HVAC Project Manager',
color: '#5a9d76',
img: 'https://img.mobiscroll.com/demos/m3.png'
}
]
const typeResources: MbscResource[] = [
{
id: 'installation',
name: 'installation',
color: '#3b5998'
},
{
id: 'maintenance',
name: 'maintenance',
color: '#2d7a4f'
},
{
id: 'repair',
name: 'repair',
color: '#b8621b'
},
{
id: 'inspection',
name: 'inspection',
color: '#6b4fa3'
},
{
id: 'upgrade',
name: 'upgrade',
color: '#a03a3a'
}
]
const selectData: MbscSelectData[] = [
{
text: 'View by Assignee',
value: 'assignee'
},
{
text: 'View by Type',
value: 'type'
}
]
const defaultEvents: MbscCalendarEvent[] = [
{
id: 1,
title: 'Office Tower HVAC System Overhaul',
start: '2026-01-05T00:00',
end: '2026-03-15T00:00',
resource: 1,
type: 'upgrade',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 2,
title: 'Executive Floor Climate Control Installation',
start: '2026-01-10T00:00',
end: '2026-03-20T00:00',
resource: 1,
type: 'installation',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 3,
title: 'Headquarters Heating System Maintenance',
start: '2026-02-01T00:00',
end: '2026-03-31T00:00',
resource: 1,
type: 'maintenance',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 4,
title: 'Building Infrastructure Assessment',
start: '2026-01-15T00:00',
end: '2026-03-25T00:00',
resource: 2,
type: 'inspection',
clientGroup: 'Enterprise Solutions Group'
},
{
id: 5,
title: 'Campus Facilities Modernization',
start: '2026-02-05T00:00',
end: '2026-03-31T00:00',
resource: 2,
type: 'upgrade',
clientGroup: 'Enterprise Solutions Group'
},
// Showing partial data. Download full source.
]
const zoomLevel = ref<'month' | 'year' | 'day' | 'week' | 'quarter' | 'hour'>('month')
const myView = computed<MbscEventcalendarView>(() => ({
timeline: {
type: 'year',
resolutionHorizontal: zoomLevel.value,
eventHeight: 'variable'
}
}))
// Reactive state
const rawEvents = ref<MbscCalendarEvent[]>([...defaultEvents])
const displayEvents = ref<MbscCalendarEvent[]>([])
const groupedEvents = ref<MbscCalendarEvent[]>([])
const myResources = ref<MbscResource[]>(assigneeResources)
const groupBy = ref<string>('assignee')
const groupByClientQuarter = ref<boolean>(false)
const isToastOpen = ref<boolean>(false)
const toastMessage = ref<string>('')
const calendarRef = ref<typeof MbscEventcalendar>()
const isConfirmOpen = ref<boolean>(false)
const confirmMessage = ref<string>('')
let confirmCallback: (() => void) | null = null
const editingEventId = ref<string | number | null>(null)
const editEventTitle = ref<string>('')
const editEventDateRange = ref<Date[]>([])
const isDatePickerOpen = ref<boolean>(false)
function groupEventsByClientQuarter(events: MbscCalendarEvent[]) {
const groups = new Map<
string,
{
resource: string | number | undefined
clientGroup: string
year: number
period: number
events: MbscCalendarEvent[]
}
>()
const result: MbscCalendarEvent[] = []
// Save old collapsed states
const oldCollapsedStates: Record<string, boolean | undefined> = {}
groupedEvents.value.forEach((ge: MbscCalendarEvent) => {
const stateKey = `${ge.resource}-${ge.clientGroup}-${ge.year}-${ge.period}`
oldCollapsedStates[stateKey] = ge.collapsed
})
// Group events
events.forEach((event: MbscCalendarEvent) => {
const resourceId = groupBy.value === 'assignee' ? event.resource : event.type
const eventStart = new Date(event.start as string | number | Date)
const month = eventStart.getMonth()
const year = eventStart.getFullYear()
const period = Math.floor(month / 3)
const groupKey = `${resourceId}-${event.clientGroup}-${year}-${period}`
if (!groups.has(groupKey)) {
groups.set(groupKey, {
resource: resourceId,
clientGroup: event.clientGroup,
year,
period,
events: []
})
}
groups.get(groupKey)!.events.push(event)
})
// Create grouped events
groups.forEach((groupData, groupKey) => {
const periodEvents = [...groupData.events].sort((a, b) =>
a.start! < b.start! ? -1 : a.start! > b.start! ? 1 : 0
)
const resourceList = groupBy.value === 'assignee' ? assigneeResources : typeResources
const color = resourceList.find((r) => r.id === groupData.resource)?.color
const eventIds = periodEvents.map((e) => e.id).join('-')
const earliestStart = periodEvents[0].start
const latestEnd = periodEvents.reduce(
(latest, e) => (e.end! > latest! ? e.end : latest),
periodEvents[0].end
)
const newId = `group-${groupKey}-${eventIds}`
const stateKey = `${groupData.resource}-${groupData.clientGroup}-${groupData.year}-${groupData.period}`
result.push({
id: newId,
title: groupData.clientGroup,
resource: groupData.resource,
clientGroup: groupData.clientGroup,
year: groupData.year,
period: groupData.period,
start: earliestStart,
end: latestEnd,
color,
count: periodEvents.length,
originalEvents: periodEvents,
collapsed: stateKey in oldCollapsedStates ? oldCollapsedStates[stateKey] : true
})
})
return result
}
function prepareEventsForDisplay(events: MbscCalendarEvent[]) {
return events.map((event: MbscCalendarEvent) => ({
id: event.id,
title: event.title,
start: event.start,
end: event.end,
resource: groupBy.value === 'assignee' ? event.resource : event.type,
type: event.type,
assignee: event.resource,
clientGroup: event.clientGroup,
color: '#f8f9fa'
}))
}
function updateView() {
const currentResources = groupBy.value === 'assignee' ? assigneeResources : typeResources
if (groupByClientQuarter.value) {
groupedEvents.value = groupEventsByClientQuarter(rawEvents.value)
displayEvents.value = groupedEvents.value
} else {
groupedEvents.value = []
displayEvents.value = prepareEventsForDisplay(rawEvents.value)
}
myResources.value = currentResources
}
function handleEventUpdated(args: MbscEventUpdateEvent) {
const updatedEvent = args.event
const oldEvent = args.oldEvent!
const newStart = new Date(updatedEvent.start as string | number | Date).getTime()
const newEnd = new Date(updatedEvent.end as string | number | Date).getTime()
if (groupByClientQuarter.value) {
const oldStart = new Date(oldEvent.start as string | number | Date).getTime()
const oldEnd = new Date(oldEvent.end as string | number | Date).getTime()
const startDelta = newStart - oldStart
const endDelta = newEnd - oldEnd
if (startDelta === 0 && endDelta === 0) return
const movedGroupedEvent = groupedEvents.value.find((ge) => ge.id === oldEvent.id)
if (!movedGroupedEvent) return
const {
clientGroup: clientGroupName,
resource: resourceId,
collapsed: wasCollapsed
} = movedGroupedEvent
const isMove = startDelta === endDelta
let eventsToUpdate: MbscCalendarEvent[]
if (isMove) {
eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent: MbscCalendarEvent) => ({
...originalEvent,
start: new Date(new Date(originalEvent.start as string | Date).getTime() + startDelta),
end: new Date(new Date(originalEvent.end as string | Date).getTime() + startDelta)
}))
} else {
const oldGroupDuration = oldEnd - oldStart
const newGroupDuration = newEnd - newStart
eventsToUpdate = movedGroupedEvent.originalEvents.map((originalEvent: MbscCalendarEvent) => {
const evStart = new Date(originalEvent.start as string | Date).getTime()
const evEnd = new Date(originalEvent.end as string | Date).getTime()
const relativeStart = oldGroupDuration > 0 ? (evStart - oldStart) / oldGroupDuration : 0
const relativeEnd = oldGroupDuration > 0 ? (evEnd - oldStart) / oldGroupDuration : 1
const mappedStart = newStart + relativeStart * newGroupDuration
const mappedEnd = newStart + relativeEnd * newGroupDuration
const childDurationDays = (mappedEnd - mappedStart) / (1000 * 60 * 60 * 24)
return {
...originalEvent,
start: new Date(mappedStart),
end: new Date(childDurationDays < 1 ? mappedStart + 1000 * 60 * 60 * 24 : mappedEnd)
}
})
}
const updatedMap = new Map(eventsToUpdate.map((e: MbscCalendarEvent) => [e.id, e]))
rawEvents.value = rawEvents.value.map((e: MbscCalendarEvent) =>
updatedMap.has(e.id) ? updatedMap.get(e.id)! : e
)
updateView()
const newYear = new Date(eventsToUpdate[0].start as string | Date).getFullYear()
const newPeriod = Math.floor(new Date(eventsToUpdate[0].start as string | Date).getMonth() / 3)
const newGroupedEvent = groupedEvents.value.find(
(ge: MbscCalendarEvent) =>
ge.resource === resourceId &&
ge.clientGroup === clientGroupName &&
ge.year === newYear &&
ge.period === newPeriod
)
if (newGroupedEvent) {
newGroupedEvent.collapsed = wasCollapsed
displayEvents.value = [...groupedEvents.value]
}
const actionLabel = isMove ? 'moved' : 'resized'
toastMessage.value = `${eventsToUpdate.length} event(s) for ${clientGroupName} have been ${actionLabel}.`
isToastOpen.value = true
} else {
const oldResource = oldEvent.resource
const newResource = updatedEvent.resource
const resourceChanged = oldResource !== newResource
rawEvents.value = rawEvents.value.map((e: MbscCalendarEvent) => {
if (e.id !== updatedEvent.id) return e
const update: Partial<MbscCalendarEvent> = {
start: updatedEvent.start,
end: updatedEvent.end
}
if (resourceChanged) {
if (groupBy.value === 'assignee') update.resource = newResource
else update.type = String(newResource)
}
return { ...e, ...update }
})
if (resourceChanged) {
let fromName: string | undefined
let toName: string | undefined
if (groupBy.value === 'assignee') {
const fromRes = assigneeResources.find((r) => r.id === oldResource)
const toRes = assigneeResources.find((r) => r.id === newResource)
fromName = fromRes ? fromRes.name : String(oldResource)
toName = toRes ? toRes.name : String(newResource)
} else {
fromName = String(oldResource)
toName = String(newResource)
}
toastMessage.value = `"${updatedEvent.title}" moved from ${fromName} to ${toName}.`
isToastOpen.value = true
}
}
}
function toggleGroupCollapse(groupEvent: MbscCalendarEvent) {
groupedEvents.value = groupedEvents.value.map((ge) =>
ge.id === groupEvent.id ? { ...ge, collapsed: !ge.collapsed } : ge
)
displayEvents.value = [...groupedEvents.value]
// Refresh calendar to recalculate row heights
setTimeout(() => {
calendarRef.value?.instance?.refresh()
}, 200)
}
function getUniqueItems(originalEvents: MbscCalendarEvent[]) {
const uniqueItems: Record<string, MbscResource> = {}
originalEvents.forEach((ev: MbscCalendarEvent) => {
if (groupBy.value === 'assignee') {
const typeObj = typeResources.find((r) => r.id === ev.type)
if (typeObj) uniqueItems[typeObj.id] = typeObj
} else {
const emp = assigneeResources.find((r) => r.id === ev.resource)
if (emp) uniqueItems[emp.id] = emp
}
})
return {
count: Object.keys(uniqueItems).length,
label: groupBy.value === 'assignee' ? 'type' : 'employee'
}
}
function getDetailInfo(event: MbscCalendarEvent) {
let detailText: string | undefined = ''
let typeDotColor: string | undefined = undefined
let avatarUrl: string | undefined = undefined
if (groupBy.value === 'assignee') {
const typeObj = typeResources.find((t) => t.id === event.type)
if (typeObj) {
detailText = typeObj.name
typeDotColor = typeObj.color
}
} else {
const employee = assigneeResources.find((r) => r.id === event.assignee)
if (employee) {
detailText = employee.name
avatarUrl = employee.img
}
}
return { detailText, typeDotColor, avatarUrl }
}
function formatEventDate(date: string | number | Date) {
return formatDate('DD MMM', new Date(date))
}
function getEventMarginLeft(ev: MbscCalendarEvent, group: MbscCalendarEvent): string {
const groupStart = new Date(group.start as string | Date).getTime()
const groupEnd = new Date(group.end as string | Date).getTime()
const groupDuration = groupEnd - groupStart
const evStart = new Date(ev.start as string | Date).getTime()
return groupDuration > 0 ? ((evStart - groupStart) / groupDuration) * 100 + '%' : '0%'
}
function getEventWidth(ev: MbscCalendarEvent, group: MbscCalendarEvent): string {
const groupStart = new Date(group.start as string | Date).getTime()
const groupEnd = new Date(group.end as string | Date).getTime()
const groupDuration = groupEnd - groupStart
const evStart = new Date(ev.start as string | Date).getTime()
const evEnd = new Date(ev.end as string | Date).getTime()
return groupDuration > 0 ? ((evEnd - evStart) / groupDuration) * 100 + '%' : '100%'
}
function handleEditEvent(ev: MbscCalendarEvent) {
editingEventId.value = ev.id ?? null
editEventTitle.value = ev.title ?? ''
editEventDateRange.value = [
new Date(ev.start as string | Date),
new Date(ev.end as string | Date)
]
isDatePickerOpen.value = true
}
function handleEditDateChange(args: { value: Date[] }) {
const dates = args.value
const startVal = dates && dates[0]
const endVal = dates && dates[1]
if (editingEventId.value === null || !startVal || !endVal) return
const oldEvent = rawEvents.value.find((e) => e.id === editingEventId.value)
if (!oldEvent) return
const oldQuarter = Math.floor(new Date(oldEvent.start as string | Date).getMonth() / 3)
const newQuarter = Math.floor(new Date(startVal).getMonth() / 3)
const oldYear = new Date(oldEvent.start as string | Date).getFullYear()
const newYear = new Date(startVal).getFullYear()
const quarterChanged =
groupByClientQuarter.value && (oldQuarter !== newQuarter || oldYear !== newYear)
const applyUpdate = () => {
rawEvents.value = rawEvents.value.map((e) =>
e.id === editingEventId.value ? { ...e, start: startVal, end: endVal } : e
)
updateView()
}
if (quarterChanged) {
const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4']
const fromLabel = `${quarterNames[oldQuarter]} ${oldYear}`
const toLabel = `${quarterNames[newQuarter]} ${newYear}`
confirmCallback = () => {
applyUpdate()
toastMessage.value = `"${oldEvent.title}" moved to ${toLabel}.`
isToastOpen.value = true
}
confirmMessage.value = `"${oldEvent.title}" will move from ${fromLabel} to ${toLabel}. Do you want to continue?`
isConfirmOpen.value = true
} else {
applyUpdate()
toastMessage.value = `"${oldEvent.title}" dates updated.`
isToastOpen.value = true
}
}
function handleConfirmClose(result: boolean) {
isConfirmOpen.value = false
if (result && confirmCallback) {
confirmCallback()
confirmCallback = null
}
}
// Initialize view on mount
updateView()
</script>
<template>
<MbscEventcalendar
ref="calendarRef"
cssClass="mds-event-group-calendar"
:data="displayEvents"
:view="myView"
:resources="myResources"
:dragToMove="true"
:dragToResize="true"
:dragToCreate="false"
:clickToCreate="false"
:dragBetweenResources="!groupByClientQuarter"
@event-updated="handleEventUpdated"
>
<template #header>
<MbscCalendarNav />
<div class="mbsc-flex mbsc-flex-1-0 mbsc-justify-content-end mds-event-group-header-controls">
<MbscCheckbox
v-model="groupByClientQuarter"
@change="updateView"
theme="material"
label="Group by Client/Quarter"
/>
<MbscSelect
inputStyle="box"
display="anchored"
:touchUi="false"
:data="selectData"
v-model="groupBy"
@change="updateView"
/>
</div>
<MbscSegmentedGroup v-model="zoomLevel">
<MbscSegmented value="quarter">Quarterly</MbscSegmented>
<MbscSegmented value="month">Monthly</MbscSegmented>
<MbscSegmented value="week">Weekly</MbscSegmented>
</MbscSegmentedGroup>
<MbscCalendarPrev />
<MbscCalendarToday />
<MbscCalendarNext />
</template>
<template #resource="resource">
<div v-if="groupBy === 'assignee'" class="mbsc-flex">
<img :alt="resource.name" class="mds-event-group-avatar" :src="resource.img" />
<div class="mds-event-group-cont">
<div class="mds-event-group-name">{{ resource.name }}</div>
<div class="mds-event-group-title">{{ resource.title }}</div>
</div>
</div>
<div v-else class="mbsc-flex mds-event-group-type-resource">
<div class="mds-event-group-type-badge" :style="{ backgroundColor: resource.color }"></div>
<div class="mds-event-group-type-name">{{ resource.name }}</div>
</div>
</template>
<template #timelineEvent="event">
<!-- Grouped Event Template -->
<div
v-if="groupByClientQuarter"
class="mbsc-flex mds-event-group-task mds-event-group-task-client"
:class="{ expanded: !event.original.collapsed }"
:style="{ borderLeftColor: event.original.color }"
>
<div class="mbsc-flex mds-event-group-content">
<div class="mds-event-group-title-text mds-event-group-text-truncate">
{{ event.original.clientGroup }}
</div>
<div class="mbsc-flex mds-event-group-right">
<div class="mbsc-flex mds-event-group-meta">
<div class="mds-event-group-date-range">
{{ formatEventDate(event.original.start) }} -
{{ formatEventDate(event.original.end) }}
</div>
<div class="mds-event-group-count">
{{ event.original.count }} task{{ event.original.count > 1 ? 's' : '' }},
{{ getUniqueItems(event.original.originalEvents).count }}
{{ getUniqueItems(event.original.originalEvents).label
}}{{ getUniqueItems(event.original.originalEvents).count > 1 ? 's' : '' }}
</div>
</div>
<div
class="mbsc-flex mds-event-group-icon mbsc-icon mbsc-font-icon mbsc-icon-material-keyboard-arrow-down"
@click.stop="toggleGroupCollapse(event.original)"
></div>
</div>
</div>
<div class="mds-event-group-events">
<div class="mds-event-group-events-inner">
<div
v-for="ev in event.original.originalEvents"
v-show="!event.original.collapsed"
:key="ev.id"
class="mds-event-group-original-event"
:title="`${ev.title}, ${formatEventDate(ev.start)} - ${formatEventDate(ev.end)}`"
:style="{
marginLeft: getEventMarginLeft(ev, event.original),
width: getEventWidth(ev, event.original)
}"
>
<div class="mbsc-flex mds-event-group-event-content">
<div class="mds-event-group-event-title mds-event-group-text-truncate">
{{ ev.title }}
</div>
<div class="mbsc-flex mds-event-group-event-right">
<div class="mds-event-group-event-date mds-event-group-text-truncate">
{{ formatEventDate(ev.start) }} - {{ formatEventDate(ev.end) }}
</div>
<div class="mbsc-flex mds-event-group-event-detail">
<img
v-if="getDetailInfo(ev).avatarUrl"
:src="getDetailInfo(ev).avatarUrl"
:alt="getDetailInfo(ev).detailText"
class="mds-event-group-event-avatar"
/>
<span
v-if="getDetailInfo(ev).typeDotColor"
class="mds-event-group-type-dot"
:style="{ backgroundColor: getDetailInfo(ev).typeDotColor }"
></span>
<div class="mds-event-group-event-info mds-event-group-text-truncate">
{{ getDetailInfo(ev).detailText }}
</div>
</div>
</div>
<div
class="mds-event-group-edit-btn mbsc-flex mbsc-icon mbsc-font-icon mbsc-icon-pencil"
@click.stop="handleEditEvent(ev)"
></div>
</div>
</div>
</div>
</div>
</div>
<!-- Simple Event Template -->
<div v-else class="mbsc-flex mds-event-simple">
<div class="mds-event-simple-title">
<div class="mds-event-simple-title-inner mds-event-group-text-truncate">
{{ event.original.title }}
</div>
</div>
<div class="mbsc-flex mds-event-simple-right">
<div class="mds-event-simple-date">
{{ formatEventDate(event.original.start) }} - {{ formatEventDate(event.original.end) }}
</div>
<div
v-if="getDetailInfo(event.original).detailText"
class="mbsc-flex mds-event-simple-subtitle-wrapper"
>
<img
v-if="getDetailInfo(event.original).avatarUrl"
:src="getDetailInfo(event.original).avatarUrl"
:alt="getDetailInfo(event.original).detailText"
class="mds-event-simple-avatar"
/>
<span
v-if="getDetailInfo(event.original).typeDotColor"
class="mds-event-simple-type-dot"
:style="{ backgroundColor: getDetailInfo(event.original).typeDotColor }"
></span>
<div
class="mbsc-flex mds-event-simple-subtitle"
:style="{ marginLeft: groupBy === 'type' ? '25px' : '' }"
>
{{ getDetailInfo(event.original).detailText }}
</div>
</div>
</div>
</div>
</template>
</MbscEventcalendar>
<MbscToast :message="toastMessage" :isOpen="isToastOpen" @close="isToastOpen = false" />
<MbscDatepicker
:controls="['calendar']"
select="range"
display="center"
:touchUi="false"
:showRangeLabels="false"
:headerText="editEventTitle"
:value="editEventDateRange"
:isOpen="isDatePickerOpen"
@close="isDatePickerOpen = false"
@change="handleEditDateChange"
/>
<MbscConfirm
:isOpen="isConfirmOpen"
title="Move to different group"
:message="confirmMessage"
okText="Move"
cancelText="Cancel"
@close="handleConfirmClose"
/>
</template>
<style>
.mds-event-group-calendar .mbsc-timeline-row {
height: 140px;
}
.mds-event-group-calendar .mbsc-timeline-resource-col {
width: 240px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-textfield-wrapper-box {
margin: 10px 20px;
}
.mds-event-group-calendar .mbsc-timeline-resource-title {
text-transform: capitalize;
}
.mds-event-group-avatar {
width: 40px;
height: 40px;
}
.mds-event-group-cont {
padding: 0 7px;
}
.mds-event-group-name {
font-size: 14px;
line-height: 24px;
}
.mds-event-group-title {
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
.mds-event-group-type-resource {
align-items: center;
padding: 8px 0;
}
.mds-event-group-type-badge {
width: 12px;
height: 12px;
border-radius: 50%;
margin: 0 10px;
}
.mds-event-group-type-name {
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.mds-event-group-calendar .mbsc-schedule-event {
min-width: 72px;
}
.mds-event-group-calendar .mbsc-schedule-event:has(.mds-event-group-task-client) {
min-width: 120px;
}
.mds-event-group-task-client {
background-color: #ddd;
border-left: 4px solid;
border-radius: 0 8px 8px 0;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.08);
flex-direction: column;
container-type: inline-size;
min-width: 120px;
}
.mds-event-group-content {
justify-content: space-between;
align-items: center;
padding: 10px 14px;
height: 42px;
box-sizing: border-box;
gap: 8px;
}
.mds-event-group-title-text {
font-size: 14px;
font-weight: 600;
color: #1e293b;
line-height: 20px;
min-width: 0;
position: sticky;
left: 250px;
}
.mds-event-group-right {
align-items: center;
z-index: 1;
background-color: #ddd;
}
.mds-event-group-meta {
flex-direction: column;
align-items: flex-end;
flex: 1;
margin: 0 12px;
}
.mds-event-group-date-range {
color: #575757;
font-size: 11px;
margin-bottom: 2px;
}
.mds-event-group-count {
font-size: 11px;
white-space: nowrap;
}
.mds-event-group-icon {
font-size: 20px;
cursor: pointer;
transition: transform 0.2s ease;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
transform: rotate(0deg);
}
.mds-event-group-task.expanded .mds-event-group-icon {
transform: rotate(180deg);
}
.mds-event-group-events {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.mds-event-group-events-inner {
overflow: hidden;
padding: 0 14px 4px;
transition: padding 0.2s ease;
}
.mds-event-group-task.expanded .mds-event-group-events {
grid-template-rows: 1fr;
}
.mds-event-group-original-event {
background: #f1f1f1;
border-radius: 6px;
margin-bottom: 6px;
padding: 6px 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
box-sizing: border-box;
min-height: 38px;
min-width: 100px;
overflow: hidden;
container-type: inline-size;
}
@container (max-width: 180px) {
.mds-event-group-meta {
display: none;
}
}
@container (max-width: 130px) {
.mds-event-group-event-right {
display: none;
}
.mds-event-simple-right {
display: none;
}
}
.mds-event-group-event-content {
justify-content: space-between;
align-items: center;
line-height: 14px;
}
.mds-event-group-event-title {
font-weight: 600;
color: #1e293b;
font-size: 13px;
line-height: 18px;
flex: 1;
min-width: 30px;
margin-right: 8px;
}
.mds-event-group-text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mds-event-group-event-right {
flex-direction: column;
align-items: flex-end;
min-width: 0;
}
.mds-event-group-event-date {
font-size: 11px;
color: #94a3b8;
text-align: right;
margin-bottom: 2px;
max-width: 100%;
}
.mds-event-group-event-detail {
align-items: center;
justify-content: flex-end;
overflow: hidden;
max-width: 100%;
}
.mds-event-group-event-info {
font-size: 11px;
font-weight: 700;
color: #64748b;
text-transform: capitalize;
}
.mds-event-group-event-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 6px;
}
.mds-event-group-type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.mds-event-group-edit-btn {
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
color: #94a3b8;
padding: 4px;
margin-left: 8px;
border-radius: 4px;
transition:
color 0.15s ease,
background-color 0.15s ease;
flex-shrink: 0;
}
.mds-event-group-edit-btn:hover {
color: #1e293b;
background-color: rgba(0, 0, 0, 0.06);
}
.mds-event-simple {
padding: 10px 12px;
border-radius: 6px;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.15),
0 1px 2px rgba(0, 0, 0, 0.1);
justify-content: space-between;
align-items: center;
height: 42px;
box-sizing: border-box;
color: #2c2c2c;
background-color: #ddd;
container-type: inline-size;
gap: 8px;
}
.mds-event-simple-title {
font-size: 13px;
font-weight: 600;
position: sticky;
left: 250px;
min-width: 0;
}
.mds-event-simple-title-inner {
display: inline-block;
max-width: 100%;
}
.mds-event-simple-right {
flex-direction: column;
z-index: 1;
background-color: #ddd;
}
.mds-event-simple-date {
font-size: 11px;
opacity: 0.85;
white-space: nowrap;
text-align: right;
margin-bottom: 2px;
}
.mds-event-simple-subtitle-wrapper {
position: relative;
align-items: center;
justify-content: flex-end;
}
.mds-event-simple-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 6px;
position: absolute;
left: 0;
}
.mds-event-simple-type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin: 0 6px;
}
.mds-event-simple-subtitle {
font-size: 11px;
font-weight: 700;
text-transform: capitalize;
white-space: nowrap;
}
.mds-event-group-header-controls {
align-items: center;
}
.mds-event-group-select .mbsc-textfield {
width: 210px;
max-height: 34px;
}
.mds-event-group-select .mbsc-ios.mbsc-select-icon {
font-size: 13px;
top: 8px;
}
.mds-event-group-calendar .mbsc-ios.mbsc-segmented-button {
padding: 1px 12px;
}
</style>