Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
::ng-deep .dx-scheduler-appointment {
color: #242424;
}

.options {
padding: 20px;
background-color: rgba(191, 191, 191, 0.15);
margin-top: 20px;
}

.option {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}

::ng-deep .hide-informer .dx-item:has(.conflict-informer) {
display: none !important;
}

::ng-deep .conflict-informer {
background-color: #FCEAE8;
color: #C50F1F;
font-size: 12px;
padding: 8px 12px;
}

::ng-deep .dx-dialog .dx-overlay-content {
width: 280px;
}

::ng-deep .dx-dialog .dx-dialog-content {
padding-bottom: 16px;
}

::ng-deep .dx-dialog .dx-dialog-buttons {
padding-top: 0;
padding-bottom: 16px;
}

::ng-deep .dx-dialog .dx-toolbar-center,
::ng-deep .dx-dialog .dx-button {
width: 100%;
}

::ng-deep .dx-scheduler-form-main-group .dx-item:last-child,
::ng-deep .dx-scheduler-form-main-group .dx-item:last-child .dx-field-item-content,
::ng-deep .dx-scheduler-form-main-group .dx-item:last-child .dx-item:last-child,
::ng-deep .dx-scheduler-form-main-group .dx-item:last-child .dx-item:last-child .dx-field-item-content {
overflow: visible;
}

::ng-deep .dx-scheduler-form-recurrence-group.dx-scheduler-form-recurrence-group-hidden,
::ng-deep .dx-scheduler-form-main-group.dx-scheduler-form-recurrence-group-hidden {
top: 41px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<dx-scheduler
[dataSource]="appointmentsData"
[views]="views"
currentView="week"
[startDayHour]="9"
[endDayHour]="19"
[currentDate]="currentDate"
[height]="600"
[showAllDayPanel]="false"
[resources]="resources"
[editing]="editingOptions"
(onAppointmentAdding)="onAppointmentAdding($event)"
(onAppointmentUpdating)="onAppointmentUpdating($event)"
></dx-scheduler>
<div class="options">
<div class="option">
<span>Overlapping Rule</span>
<dx-select-box
[items]="overlappingRuleItems"
valueExpr="value"
displayExpr="text"
value="sameResource"
[width]="200"
(onValueChanged)="onOverlappingRuleChanged($event)"
></dx-select-box>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { Component, enableProdMode, provideZoneChangeDetection } from '@angular/core';
import { DxSchedulerModule, DxSelectBoxModule } from 'devextreme-angular';
import { DxSchedulerTypes } from 'devextreme-angular/ui/scheduler';
import { custom as customDialog } from 'devextreme/ui/dialog';
import type dxScheduler from 'devextreme/ui/scheduler';
import { Appointment, Assignee, Service, assignees } from './app.service';

if (!/localhost/.test(document.location.host)) {
enableProdMode();
}

let modulePrefix = '';
// @ts-ignore
if (window && window.config?.packageConfigPaths) {
modulePrefix = '/app';
}

@Component({
selector: 'demo-app',
templateUrl: `.${modulePrefix}/app.component.html`,
styleUrls: [`.${modulePrefix}/app.component.css`],
providers: [Service],
preserveWhitespaces: true,
imports: [
DxSchedulerModule,
DxSelectBoxModule,
],
})
export class AppComponent {
appointmentsData: Appointment[];

currentDate: Date = new Date(2026, 1, 10);

views: DxSchedulerTypes.ViewType[] = ['day', 'week', 'workWeek', 'month'];

assignees: Assignee[] = assignees;

resources = [{
fieldExpr: 'assigneeId',
dataSource: assignees,
valueExpr: 'id',
colorExpr: 'color',
icon: 'user',
allowMultiple: true,
}];

overlappingRuleItems = [
{ value: 'sameResource', text: 'Allow across resources' },
{ value: 'allResources', text: 'Disallow all overlaps' },
];

private popup: any;

private form: any;

private showConflictError = false;

private overlappingRule = 'sameResource';

editingOptions = {
popup: {
onInitialized: (e: any) => { this.popup = e.component; },
onHidden: () => {
this.setConflictError(false);
this.form?.updateData('assigneeId', []);
},
},
form: {
labelMode: 'hidden',
elementAttr: { class: 'hide-informer' },
onInitialized: (e: any) => {
this.form = e.component;
this.form.on('fieldDataChanged', (event: any) => {
if (this.showConflictError && ['startDate', 'endDate', 'assigneeId', 'recurrenceRule'].includes(event.dataField)) {
this.setConflictError(false);
this.form.validate();
}
});
},
items: [
{
name: 'conflictInformer',
template: (_data: unknown, element: any) => {
const div = document.createElement('div');
div.className = 'conflict-informer';
div.textContent = 'This time slot conflicts with another appointment.';
(element[0] ?? element).appendChild(div);
},
},
{
name: 'mainGroup',
items: [
'subjectGroup',
'dateGroup',
'repeatGroup',
{
name: 'assigneeIdGroup',
items: [
'assigneeIdIcon',
{
name: 'assigneeId',
isRequired: true,
editorOptions: {
onValueChanged: (e: any) => {
if (e.value.length > 1) {
e.component.option('value', [e.value[e.value.length - 1]]);
}
},
tagTemplate: (tagData: Assignee) => {
const container = document.createElement('div');
container.className = 'dx-tag-content';
container.style.backgroundColor = tagData.color;
container.style.borderColor = tagData.color;
const span = document.createElement('span');
span.textContent = tagData.text;
const removeBtn = document.createElement('div');
removeBtn.className = 'dx-tag-remove-button';
container.appendChild(span);
container.appendChild(removeBtn);
return container;
},
},
},
],
},
],
},
'recurrenceGroup',
],
customizeItem: (item: any) => {
if (item.name === 'allDayEditor' || item.name === 'recurrenceEndEditor') {
item.label.visible = true;
} else if (item.name === 'subjectEditor') {
item.editorOptions.placeholder = 'Add title';
}

if (item.name === 'startTimeEditor' || item.name === 'endTimeEditor') {
item.validationRules = [
{ type: 'required' },
{
type: 'custom',
message: 'Time conflict',
ignoreEmptyValue: true,
reevaluate: true,
validationCallback: () => !this.showConflictError,
},
];
}
},
},
};

constructor(service: Service) {
this.appointmentsData = service.getAppointments();
}

private setConflictError(show: boolean): void {
this.showConflictError = show;
this.form?.option('elementAttr.class', show ? '' : 'hide-informer');
}

private getNextDay(date: Date): Date {
const next = new Date(date);
next.setDate(next.getDate() + 1);
return next;
}

private getEndDate(occurrence: DxSchedulerTypes.Occurrence): Date {
return (occurrence.appointmentData as Appointment).allDay
? this.getNextDay(occurrence.startDate)
: occurrence.endDate;
}

private isOverlapping(a: DxSchedulerTypes.Occurrence, b: DxSchedulerTypes.Occurrence): boolean {
const aEnd = this.getEndDate(a);
const bEnd = this.getEndDate(b);
if ((a.startDate) >= bEnd || (b.startDate) >= aEnd) return false;
if (this.overlappingRule === 'sameResource') {
return (a.appointmentData as Appointment).assigneeId[0] === (b.appointmentData as Appointment).assigneeId[0];
}
return true;
}

private detectConflict(scheduler: dxScheduler, newAppointment: Appointment): boolean {
const allAppointments = scheduler.getDataSource().items() as Appointment[];
const startDate = new Date(newAppointment.startDate);
let endDate: Date;
if (newAppointment.recurrenceRule) {
endDate = scheduler.getEndViewDate();
} else if (newAppointment.allDay) {
endDate = this.getNextDay(startDate);
} else {
endDate = new Date(newAppointment.endDate);
}

const existingOccurrences = scheduler
.getOccurrences(startDate, endDate, allAppointments)
.filter((occurrence) => (occurrence.appointmentData as Appointment).id !== newAppointment.id);

const newOccurrences = scheduler.getOccurrences(startDate, endDate, [newAppointment]);

return newOccurrences.some((newOccurrence) =>
existingOccurrences.some((existingOccurrence) =>
this.isOverlapping(newOccurrence, existingOccurrence),
),
);
}

private alertConflictIfNeeded(
e: DxSchedulerTypes.AppointmentAddingEvent | DxSchedulerTypes.AppointmentUpdatingEvent,
appointmentData: Appointment,
): void {
if (!this.detectConflict(e.component, appointmentData)) {
this.setConflictError(false);
return;
}

e.cancel = true;

if (this.popup?.option('visible')) {
this.setConflictError(true);
this.form?.validate();
} else {
const dialog = customDialog({
showTitle: false,
messageHtml: 'This time slot conflicts with another appointment.',
buttons: [{
type: 'default',
text: 'Close',
stylingMode: 'contained',
onClick: () => {
dialog.hide();
},
}],
});
dialog.show();
}
}

onAppointmentAdding(e: DxSchedulerTypes.AppointmentAddingEvent): void {
this.alertConflictIfNeeded(e, e.appointmentData as Appointment);
}

onAppointmentUpdating(e: DxSchedulerTypes.AppointmentUpdatingEvent): void {
this.alertConflictIfNeeded(e, { ...e.appointmentData, ...e.newData } as Appointment);
}

onOverlappingRuleChanged(e: any): void {
this.overlappingRule = e.value;
}
}

bootstrapApplication(AppComponent, {
providers: [
provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }),
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';

export interface Appointment {
id: number;
text: string;
startDate: Date;
endDate: Date;
assigneeId: number[];
recurrenceRule?: string;
allDay?: boolean;
}

export interface Assignee {
id: number;
text: string;
color: string;
}

export const assignees: Assignee[] = [
{ id: 1, text: 'Samantha Bright', color: '#A7E3A5' },
{ id: 2, text: 'John Heart', color: '#CFE4FA' },
{ id: 3, text: 'Todd Hoffman', color: '#F9E2AE' },
{ id: 4, text: 'Sandra Johnson', color: '#F1BBBC' },
];

const appointments: Appointment[] = [
{ id: 1, text: 'Website Re-Design Plan', startDate: new Date(2026, 1, 9, 9, 30), endDate: new Date(2026, 1, 9, 11, 30), assigneeId: [2] },
{ id: 2, text: 'Install New Router in Dev Room', startDate: new Date(2026, 1, 9, 14, 30), endDate: new Date(2026, 1, 9, 15, 30), assigneeId: [3] },
{ id: 3, text: 'Approve Personal Computer Upgrade Plan', startDate: new Date(2026, 1, 10, 10, 0), endDate: new Date(2026, 1, 10, 11, 0), assigneeId: [1] },
{ id: 4, text: 'Final Budget Review', startDate: new Date(2026, 1, 10, 12, 0), endDate: new Date(2026, 1, 10, 13, 35), assigneeId: [1] },
{ id: 5, text: 'Install New Database', startDate: new Date(2026, 1, 11, 9, 45), endDate: new Date(2026, 1, 11, 11, 15), assigneeId: [4] },
{ id: 6, text: 'Approve New Online Marketing Strategy', startDate: new Date(2026, 1, 11, 12, 0), endDate: new Date(2026, 1, 11, 14, 0), assigneeId: [2] },
{ id: 7, text: 'Prepare 2021 Marketing Plan', startDate: new Date(2026, 1, 12, 11, 0), endDate: new Date(2026, 1, 12, 13, 30), assigneeId: [3] },
{ id: 8, text: 'Brochure Design Review', startDate: new Date(2026, 1, 12, 14, 0), endDate: new Date(2026, 1, 12, 15, 30), assigneeId: [2] },
{ id: 9, text: 'Create Icons for Website', startDate: new Date(2026, 1, 13, 10, 0), endDate: new Date(2026, 1, 13, 11, 30), assigneeId: [1] },
{ id: 10, text: 'Launch New Website', startDate: new Date(2026, 1, 13, 12, 20), endDate: new Date(2026, 1, 13, 14, 0), assigneeId: [4] },
{ id: 11, text: 'Upgrade Server Hardware', startDate: new Date(2026, 1, 13, 14, 30), endDate: new Date(2026, 1, 13, 16, 0), assigneeId: [2] },
];

@Injectable()
export class Service {
getAppointments(): Appointment[] {
return appointments;
}
}
Loading
Loading