diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.css b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.css
new file mode 100644
index 000000000000..cd972b089814
--- /dev/null
+++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.css
@@ -0,0 +1,67 @@
+::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 .dx-item:has(.conflict-informer[hidden]) {
+ display: none !important;
+}
+
+::ng-deep .conflict-informer {
+ background-color: #FCEAE8;
+ color: #C50F1F;
+ font-size: 12px;
+ padding: 0 12px;
+ height: 36px;
+ line-height: 36px;
+ box-sizing: border-box;
+ margin-bottom: 8px;
+}
+
+::ng-deep .dx-dialog:has(#conflict-dialog) .dx-overlay-content {
+ width: 280px;
+}
+
+::ng-deep .dx-dialog:has(#conflict-dialog) .dx-dialog-content {
+ padding-bottom: 16px;
+}
+
+::ng-deep .dx-dialog:has(#conflict-dialog) .dx-dialog-buttons {
+ padding-top: 0;
+ padding-bottom: 16px;
+}
+
+::ng-deep .dx-dialog:has(#conflict-dialog) .dx-toolbar-center,
+::ng-deep .dx-dialog:has(#conflict-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 #form .dx-scheduler-form-main-group,
+::ng-deep #form .dx-scheduler-form-recurrence-group {
+ padding-top: 0;
+}
+
+::ng-deep #form:has(.conflict-informer:not([hidden])) .dx-scheduler-form-recurrence-group.dx-scheduler-form-recurrence-group-hidden,
+::ng-deep #form:has(.conflict-informer:not([hidden])) .dx-scheduler-form-main-group.dx-scheduler-form-main-group-hidden {
+ top: 44px;
+}
+
diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.html b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.html
new file mode 100644
index 000000000000..78d0e227ca28
--- /dev/null
+++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.html
@@ -0,0 +1,78 @@
+
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.oldData, ...e.newData } as Appointment); + } + + onOverlappingRuleChanged(e: DxSelectBoxTypes.ValueChangedEvent): void { + this.overlappingRule = e.value; + } +} + +bootstrapApplication(AppComponent, { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), + ], +}); diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.service.ts b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.service.ts new file mode 100644 index 000000000000..ee832f63b345 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.service.ts @@ -0,0 +1,111 @@ +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; + } +} diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/index.html b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/index.html new file mode 100644 index 000000000000..1ab1fb54a1df --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/index.html @@ -0,0 +1,26 @@ + + + +This time slot conflicts with another appointment.
', + buttons: [{ + type: 'default', + text: 'Close', + stylingMode: 'contained', + onClick: () => { + dialog.hide(); + }, + }], + }); + dialog.show(); + } + }, [setConflictError]); + + const onAppointmentAdding = useCallback((e: SchedulerTypes.AppointmentAddingEvent) => { + alertConflictIfNeeded(e, e.appointmentData as Appointment); + }, [alertConflictIfNeeded]); + + const onAppointmentUpdating = useCallback((e: SchedulerTypes.AppointmentUpdatingEvent) => { + alertConflictIfNeeded(e, { ...e.oldData, ...e.newData } as Appointment); + }, [alertConflictIfNeeded]); + + const popupOptions = useMemo(() => ({ + onInitialized: (e: PopupTypes.InitializedEvent) => { + popupRef.current = e.component ?? null; + }, + onHidden: () => { + setConflictError(false); + formRef.current?.updateData('assigneeId', []); + }, + }), [setConflictError]); + + const onFormInitialized = useCallback((e: FormTypes.InitializedEvent) => { + if (!e.component) return; + formRef.current = e.component; + + e.component.on('fieldDataChanged', (fieldEvent: FormTypes.FieldDataChangedEvent) => { + if ( + showConflictErrorRef.current && + ['startDate', 'endDate', 'assigneeId', 'recurrenceRule'].includes(fieldEvent.dataField ?? '') + ) { + setConflictError(false); + formRef.current?.validate(); + } + }); + }, [setConflictError]); + + const customizeItem = useCallback((item: FormTypes.SimpleItem) => { + if (item.name === 'allDayEditor' || item.name === 'recurrenceEndEditor') { + item.label = { ...item.label, visible: true }; + } else if (item.name === 'subjectEditor') { + item.editorOptions = item.editorOptions || {}; + 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: () => !showConflictErrorRef.current, + }, + ]; + } + }, []); + + return ( + <> +This time slot conflicts with another appointment.
', + buttons: [ + { + type: 'default', + text: 'Close', + stylingMode: 'contained', + onClick: () => { + dialog.hide(); + }, + }, + ], + }); + dialog.show(); + } + }, + [setConflictError], + ); + const onAppointmentAdding = useCallback( + (e) => { + alertConflictIfNeeded(e, e.appointmentData); + }, + [alertConflictIfNeeded], + ); + const onAppointmentUpdating = useCallback( + (e) => { + alertConflictIfNeeded(e, { ...e.oldData, ...e.newData }); + }, + [alertConflictIfNeeded], + ); + const popupOptions = useMemo( + () => ({ + onInitialized: (e) => { + popupRef.current = e.component ?? null; + }, + onHidden: () => { + setConflictError(false); + formRef.current?.updateData('assigneeId', []); + }, + }), + [setConflictError], + ); + const onFormInitialized = useCallback( + (e) => { + if (!e.component) return; + formRef.current = e.component; + e.component.on('fieldDataChanged', (fieldEvent) => { + if ( + showConflictErrorRef.current && + ['startDate', 'endDate', 'assigneeId', 'recurrenceRule'].includes( + fieldEvent.dataField ?? '', + ) + ) { + setConflictError(false); + formRef.current?.validate(); + } + }); + }, + [setConflictError], + ); + const customizeItem = useCallback((item) => { + if (item.name === 'allDayEditor' || item.name === 'recurrenceEndEditor') { + item.label = { ...item.label, visible: true }; + } else if (item.name === 'subjectEditor') { + item.editorOptions = item.editorOptions || {}; + 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: () => !showConflictErrorRef.current, + }, + ]; + } + }, []); + return ( + <> +This time slot conflicts with another appointment.
', + buttons: [{ + type: 'default', + text: 'Close', + stylingMode: 'contained', + onClick: () => { + dialog.hide(); + }, + }], + }); + dialog.show(); + } + } + + function getNextDay(date) { + const next = new Date(date); + next.setDate(next.getDate() + 1); + return next; + } + + function getEndDate(occurrence) { + return occurrence.appointmentData.allDay + ? getNextDay(occurrence.startDate) + : occurrence.endDate; + } + + function isOverlapping(a, b) { + const aEnd = getEndDate(a); + const bEnd = getEndDate(b); + if (a.startDate >= bEnd || b.startDate >= aEnd) return false; + if (overlappingRule === 'sameResource') { + return a.appointmentData.assigneeId[0] === b.appointmentData.assigneeId[0]; + } + return true; + } + + function detectConflict(newAppointment) { + const allAppointments = scheduler.getDataSource().items(); + const startDate = new Date(newAppointment.startDate); + let endDate; + if (newAppointment.recurrenceRule) { + endDate = scheduler.getEndViewDate(); + } else if (newAppointment.allDay) { + endDate = getNextDay(startDate); + } else { + endDate = new Date(newAppointment.endDate); + } + + const existingOccurrences = scheduler + .getOccurrences(startDate, endDate, allAppointments) + .filter((occurrence) => occurrence.appointmentData.id !== newAppointment.id); + + const newOccurrences = scheduler.getOccurrences(startDate, endDate, [newAppointment]); + + return newOccurrences.some((newOccurrence) => + existingOccurrences.some((existingOccurrence) => + isOverlapping(newOccurrence, existingOccurrence), + ), + ); + } + + $('#overlapping-rule').dxSelectBox({ + items: [ + { value: 'sameResource', text: 'Allow across resources' }, + { value: 'allResources', text: 'Disallow all overlaps' }, + ], + valueExpr: 'value', + displayExpr: 'text', + value: 'sameResource', + onValueChanged(e) { + overlappingRule = e.value; + }, + }); +}); diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/styles.css b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/styles.css new file mode 100644 index 000000000000..a9d28f566ae4 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/styles.css @@ -0,0 +1,68 @@ +.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; +} + +#overlapping-rule { + width: 200px; +} + +.hide-informer .dx-item:has(.conflict-informer) { + display: none !important; +} + +.conflict-informer { + background-color: #FCEAE8; + color: #C50F1F; + font-size: 12px; + padding: 0 12px; + height: 36px; + line-height: 36px; + box-sizing: border-box; + margin-bottom: 8px; +} + +.dx-dialog:has(#conflict-dialog) .dx-overlay-content { + width: 280px; +} + +.dx-dialog:has(#conflict-dialog) .dx-dialog-content { + padding-bottom: 16px; +} + +.dx-dialog:has(#conflict-dialog) .dx-dialog-buttons { + padding-top: 0; + padding-bottom: 16px; +} + +.dx-dialog:has(#conflict-dialog) .dx-toolbar-center, +.dx-dialog:has(#conflict-dialog) .dx-button { + width: 100%; +} + +.dx-scheduler-form-main-group .dx-item:last-child, +.dx-scheduler-form-main-group .dx-item:last-child .dx-field-item-content { + overflow: visible; +} + +#form .dx-scheduler-form-main-group, +#form .dx-scheduler-form-recurrence-group { + padding-top: 0; +} + +#form:not(.hide-informer) .dx-scheduler-form-recurrence-group.dx-scheduler-form-recurrence-group-hidden, +#form:not(.hide-informer) .dx-scheduler-form-main-group.dx-scheduler-form-main-group-hidden { + top: 44px; +} diff --git a/apps/demos/menuMeta.json b/apps/demos/menuMeta.json index ec74657b0427..3af24ca7a32b 100644 --- a/apps/demos/menuMeta.json +++ b/apps/demos/menuMeta.json @@ -3628,6 +3628,12 @@ "/Models/SampleData/PriorityResources.cs" ], "DemoType": "Web" + }, + { + "Title": "Resolve Time Conflicts", + "Name": "ResolveTimeConflicts", + "Widget": "Scheduler", + "DemoType": "Web" } ] }, diff --git a/apps/demos/testing/etalons/Scheduler-ResolveTimeConflicts (fluent.blue.light).png b/apps/demos/testing/etalons/Scheduler-ResolveTimeConflicts (fluent.blue.light).png new file mode 100644 index 000000000000..1f54b4c03a2e Binary files /dev/null and b/apps/demos/testing/etalons/Scheduler-ResolveTimeConflicts (fluent.blue.light).png differ diff --git a/apps/demos/testing/etalons/Scheduler-ResolveTimeConflicts (material.blue.light).png b/apps/demos/testing/etalons/Scheduler-ResolveTimeConflicts (material.blue.light).png new file mode 100644 index 000000000000..8331ef11e076 Binary files /dev/null and b/apps/demos/testing/etalons/Scheduler-ResolveTimeConflicts (material.blue.light).png differ