Skip to content

Commit 9691b5c

Browse files
Scheduler - Resolve Time Conflicts Demo (#32703)
Co-authored-by: Eldar Iusupzhanov <ferrarijed@gmail.com> Co-authored-by: Eldar Iusupzhanov <84278206+Tucchhaa@users.noreply.github.com>
1 parent 5b769f7 commit 9691b5c

26 files changed

Lines changed: 2276 additions & 0 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
::ng-deep .dx-scheduler-appointment {
2+
color: #242424;
3+
}
4+
5+
.options {
6+
padding: 20px;
7+
background-color: rgba(191, 191, 191, 0.15);
8+
margin-top: 20px;
9+
}
10+
11+
.option {
12+
margin-top: 10px;
13+
display: flex;
14+
align-items: center;
15+
gap: 10px;
16+
}
17+
18+
::ng-deep .hide-informer .dx-item:has(.conflict-informer) {
19+
display: none !important;
20+
}
21+
22+
::ng-deep .conflict-informer {
23+
background-color: #FCEAE8;
24+
color: #C50F1F;
25+
font-size: 12px;
26+
padding: 0 12px;
27+
height: 36px;
28+
line-height: 36px;
29+
box-sizing: border-box;
30+
margin-bottom: 8px;
31+
}
32+
33+
::ng-deep .dx-dialog:has(#conflict-dialog) .dx-overlay-content {
34+
width: 280px;
35+
}
36+
37+
::ng-deep .dx-dialog:has(#conflict-dialog) .dx-dialog-content {
38+
padding-bottom: 16px;
39+
}
40+
41+
::ng-deep .dx-dialog:has(#conflict-dialog) .dx-dialog-buttons {
42+
padding-top: 0;
43+
padding-bottom: 16px;
44+
}
45+
46+
::ng-deep .dx-dialog:has(#conflict-dialog) .dx-toolbar-center,
47+
::ng-deep .dx-dialog:has(#conflict-dialog) .dx-button {
48+
width: 100%;
49+
}
50+
51+
::ng-deep #form .dx-scheduler-form-main-group,
52+
::ng-deep #form .dx-scheduler-form-recurrence-group {
53+
padding-top: 0;
54+
}
55+
56+
::ng-deep #form:not(.hide-informer) .dx-scheduler-form-recurrence-group.dx-scheduler-form-recurrence-group-hidden,
57+
::ng-deep #form:not(.hide-informer) .dx-scheduler-form-main-group.dx-scheduler-form-main-group-hidden {
58+
top: 44px;
59+
}
60+
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<dx-scheduler
2+
[dataSource]="appointmentsData"
3+
[views]="views"
4+
currentView="week"
5+
[startDayHour]="9"
6+
[endDayHour]="19"
7+
[currentDate]="currentDate"
8+
[height]="600"
9+
[showAllDayPanel]="false"
10+
allDayPanelMode="hidden"
11+
(onAppointmentAdding)="onAppointmentAdding($event)"
12+
(onAppointmentUpdating)="onAppointmentUpdating($event)"
13+
>
14+
<dxi-scheduler-resource
15+
fieldExpr="assigneeId"
16+
[dataSource]="assignees"
17+
valueExpr="id"
18+
colorExpr="color"
19+
icon="user"
20+
[allowMultiple]="true"
21+
></dxi-scheduler-resource>
22+
23+
<dxo-scheduler-editing [popup]="popupOptions">
24+
<dxo-scheduler-form
25+
labelMode="hidden"
26+
[elementAttr]="formElementAttr"
27+
[customizeItem]="customizeItem"
28+
[onInitialized]="onFormInitialized"
29+
>
30+
<dxi-scheduler-item
31+
name="conflictInformer"
32+
template="conflictInformerTemplate"
33+
></dxi-scheduler-item>
34+
<dxi-scheduler-item name="mainGroup" itemType="group">
35+
<dxi-scheduler-item name="subjectGroup"></dxi-scheduler-item>
36+
<dxi-scheduler-item name="dateGroup"></dxi-scheduler-item>
37+
<dxi-scheduler-item name="repeatGroup"></dxi-scheduler-item>
38+
<dxi-scheduler-item name="assigneeIdGroup">
39+
<dxi-scheduler-item name="assigneeIdIcon"></dxi-scheduler-item>
40+
<dxi-scheduler-item
41+
name="assigneeId"
42+
[isRequired]="true"
43+
[editorOptions]="assigneeIdEditorOptions"
44+
></dxi-scheduler-item>
45+
</dxi-scheduler-item>
46+
</dxi-scheduler-item>
47+
<dxi-scheduler-item
48+
name="recurrenceGroup"
49+
itemType="group"
50+
></dxi-scheduler-item>
51+
</dxo-scheduler-form>
52+
</dxo-scheduler-editing>
53+
54+
<div *dxTemplate="let _ of 'conflictInformerTemplate'">
55+
<div class="conflict-informer"
56+
>This time slot conflicts with another appointment.</div
57+
>
58+
</div>
59+
60+
<div *dxTemplate="let tagData of 'assigneeTagTemplate'">
61+
<div
62+
class="dx-tag-content"
63+
[style.background-color]="tagData.color"
64+
[style.border-color]="tagData.color"
65+
>
66+
<span>{{ tagData.text }}</span>
67+
<div class="dx-tag-remove-button"></div>
68+
</div>
69+
</div>
70+
</dx-scheduler>
71+
<div class="options">
72+
<div class="option">
73+
<span>Overlapping Rule</span>
74+
<dx-select-box
75+
[items]="overlappingRuleItems"
76+
valueExpr="value"
77+
displayExpr="text"
78+
value="sameResource"
79+
(onValueChanged)="onOverlappingRuleChanged($event)"
80+
></dx-select-box>
81+
</div>
82+
</div>
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { bootstrapApplication } from '@angular/platform-browser';
2+
import { Component, enableProdMode, provideZoneChangeDetection } from '@angular/core';
3+
import { DxSchedulerModule, DxSelectBoxModule, DxTemplateModule } from 'devextreme-angular';
4+
import { DxSchedulerTypes } from 'devextreme-angular/ui/scheduler';
5+
import { DxSelectBoxTypes } from 'devextreme-angular/ui/select-box';
6+
import { DxFormTypes } from 'devextreme-angular/ui/form';
7+
import { DxPopupTypes } from 'devextreme-angular/ui/popup';
8+
import { DxTagBoxTypes } from 'devextreme-angular/ui/tag-box';
9+
import { custom as customDialog } from 'devextreme/ui/dialog';
10+
import { Appointment, Assignee, Service, assignees } from './app.service';
11+
12+
type dxScheduler = NonNullable<DxSchedulerTypes.InitializedEvent['component']>;
13+
type dxForm = NonNullable<DxFormTypes.InitializedEvent['component']>;
14+
type dxPopup = NonNullable<DxPopupTypes.InitializedEvent['component']>;
15+
16+
if (!/localhost/.test(document.location.host)) {
17+
enableProdMode();
18+
}
19+
20+
let modulePrefix = '';
21+
// @ts-ignore
22+
if (window && window.config?.packageConfigPaths) {
23+
modulePrefix = '/app';
24+
}
25+
26+
@Component({
27+
selector: 'demo-app',
28+
templateUrl: `.${modulePrefix}/app.component.html`,
29+
styleUrls: [`.${modulePrefix}/app.component.css`],
30+
providers: [Service],
31+
preserveWhitespaces: true,
32+
imports: [
33+
DxSchedulerModule,
34+
DxSelectBoxModule,
35+
DxTemplateModule,
36+
],
37+
})
38+
export class AppComponent {
39+
appointmentsData: Appointment[];
40+
41+
currentDate: Date = new Date(2026, 1, 10);
42+
43+
views: DxSchedulerTypes.ViewType[] = ['day', 'week', 'workWeek', 'month'];
44+
45+
assignees: Assignee[] = assignees;
46+
47+
overlappingRuleItems = [
48+
{ value: 'sameResource', text: 'Allow across resources' },
49+
{ value: 'allResources', text: 'Disallow all overlaps' },
50+
];
51+
52+
assigneeIdEditorOptions = {
53+
onValueChanged: (e: DxTagBoxTypes.ValueChangedEvent) => {
54+
if (e.value?.length > 1) {
55+
e.component.option('value', [e.value[e.value.length - 1]]);
56+
}
57+
},
58+
tagTemplate: 'assigneeTagTemplate',
59+
};
60+
61+
popupOptions = {
62+
onInitialized: (e: DxPopupTypes.InitializedEvent) => { this.popup = e.component; },
63+
onHidden: () => {
64+
this.setConflictError(false);
65+
this.form?.updateData('assigneeId', []);
66+
},
67+
};
68+
69+
formElementAttr = { id: 'form', class: 'hide-informer' };
70+
71+
private popup?: dxPopup;
72+
73+
private form?: dxForm;
74+
75+
private overlappingRule = 'sameResource';
76+
77+
constructor(service: Service) {
78+
this.appointmentsData = service.getAppointments();
79+
}
80+
81+
private showConflictError = false;
82+
83+
setConflictError(show: boolean): void {
84+
this.showConflictError = show;
85+
this.form?.option('elementAttr.class', show ? '' : 'hide-informer');
86+
}
87+
88+
onFormInitialized = (e: DxFormTypes.InitializedEvent): void => {
89+
this.form = e.component;
90+
this.form?.on('fieldDataChanged', (event: { dataField: string }) => {
91+
if (this.showConflictError && ['startDate', 'endDate', 'assigneeId', 'recurrenceRule'].includes(event.dataField)) {
92+
this.setConflictError(false);
93+
this.form?.validate();
94+
}
95+
});
96+
};
97+
98+
customizeItem = (item: DxFormTypes.SimpleItem): void => {
99+
if (item.name === 'allDayEditor' || item.name === 'recurrenceEndEditor') {
100+
item.label.visible = true;
101+
} else if (item.name === 'subjectEditor') {
102+
item.editorOptions = item.editorOptions || {};
103+
item.editorOptions.placeholder = 'Add title';
104+
}
105+
106+
if (item.name === 'startTimeEditor' || item.name === 'endTimeEditor') {
107+
item.validationRules = [
108+
{ type: 'required' },
109+
{
110+
type: 'custom',
111+
message: 'Time conflict',
112+
ignoreEmptyValue: true,
113+
reevaluate: true,
114+
validationCallback: () => !this.showConflictError,
115+
},
116+
];
117+
}
118+
};
119+
120+
private getNextDay(date: Date): Date {
121+
const next = new Date(date);
122+
next.setDate(next.getDate() + 1);
123+
return next;
124+
}
125+
126+
private getEndDate(occurrence: DxSchedulerTypes.Occurrence): Date {
127+
return (occurrence.appointmentData as Appointment).allDay
128+
? this.getNextDay(occurrence.startDate)
129+
: occurrence.endDate;
130+
}
131+
132+
private isOverlapping(a: DxSchedulerTypes.Occurrence, b: DxSchedulerTypes.Occurrence): boolean {
133+
const aEnd = this.getEndDate(a);
134+
const bEnd = this.getEndDate(b);
135+
if (a.startDate >= bEnd || b.startDate >= aEnd) return false;
136+
if (this.overlappingRule === 'sameResource') {
137+
return (a.appointmentData as Appointment).assigneeId[0] === (b.appointmentData as Appointment).assigneeId[0];
138+
}
139+
return true;
140+
}
141+
142+
private detectConflict(scheduler: dxScheduler, newAppointment: Appointment): boolean {
143+
const allAppointments = scheduler.getDataSource().items() as Appointment[];
144+
const startDate = new Date(newAppointment.startDate);
145+
let endDate: Date;
146+
if (newAppointment.recurrenceRule) {
147+
endDate = scheduler.getEndViewDate();
148+
} else if (newAppointment.allDay) {
149+
endDate = this.getNextDay(startDate);
150+
} else {
151+
endDate = new Date(newAppointment.endDate);
152+
}
153+
154+
const existingOccurrences = scheduler
155+
.getOccurrences(startDate, endDate, allAppointments)
156+
.filter((occurrence) => (occurrence.appointmentData as Appointment).id !== newAppointment.id);
157+
158+
const newOccurrences = scheduler.getOccurrences(startDate, endDate, [newAppointment]);
159+
160+
return newOccurrences.some((newOccurrence) =>
161+
existingOccurrences.some((existingOccurrence) =>
162+
this.isOverlapping(newOccurrence, existingOccurrence),
163+
),
164+
);
165+
}
166+
167+
private alertConflictIfNeeded(
168+
e: DxSchedulerTypes.AppointmentAddingEvent | DxSchedulerTypes.AppointmentUpdatingEvent,
169+
appointmentData: Appointment,
170+
): void {
171+
if (!this.detectConflict(e.component, appointmentData)) {
172+
this.setConflictError(false);
173+
return;
174+
}
175+
176+
e.cancel = true;
177+
178+
if (this.popup?.option('visible')) {
179+
this.setConflictError(true);
180+
this.form?.validate();
181+
} else {
182+
const dialog = customDialog({
183+
showTitle: false,
184+
messageHtml: '<p id="conflict-dialog">This time slot conflicts with another appointment.</p>',
185+
buttons: [{
186+
type: 'default',
187+
text: 'Close',
188+
stylingMode: 'contained',
189+
onClick: () => {
190+
dialog.hide();
191+
},
192+
}],
193+
});
194+
dialog.show();
195+
}
196+
}
197+
198+
onAppointmentAdding(e: DxSchedulerTypes.AppointmentAddingEvent): void {
199+
this.alertConflictIfNeeded(e, e.appointmentData as Appointment);
200+
}
201+
202+
onAppointmentUpdating(e: DxSchedulerTypes.AppointmentUpdatingEvent): void {
203+
this.alertConflictIfNeeded(e, { ...e.oldData, ...e.newData } as Appointment);
204+
}
205+
206+
onOverlappingRuleChanged(e: DxSelectBoxTypes.ValueChangedEvent): void {
207+
this.overlappingRule = e.value;
208+
}
209+
}
210+
211+
bootstrapApplication(AppComponent, {
212+
providers: [
213+
provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }),
214+
],
215+
});

0 commit comments

Comments
 (0)