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.
+
+ +
+
+ {{ tagData.text }} +
+
+
+
+
+
+ Overlapping Rule + +
+
diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.ts b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.ts new file mode 100644 index 000000000000..b9c657011c47 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Angular/app/app.component.ts @@ -0,0 +1,213 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { Component, enableProdMode, provideZoneChangeDetection } from '@angular/core'; +import { DxSchedulerModule, DxSelectBoxModule, DxTemplateModule } from 'devextreme-angular'; +import { DxSchedulerTypes } from 'devextreme-angular/ui/scheduler'; +import { DxSelectBoxTypes } from 'devextreme-angular/ui/select-box'; +import { DxFormTypes } from 'devextreme-angular/ui/form'; +import { DxPopupTypes } from 'devextreme-angular/ui/popup'; +import { custom as customDialog } from 'devextreme/ui/dialog'; +import { Appointment, Assignee, Service, assignees } from './app.service'; + +type dxScheduler = NonNullable; +type dxForm = NonNullable; +type dxPopup = NonNullable; + +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, + DxTemplateModule, + ], +}) +export class AppComponent { + appointmentsData: Appointment[]; + + currentDate: Date = new Date(2026, 1, 10); + + views: DxSchedulerTypes.ViewType[] = ['day', 'week', 'workWeek', 'month']; + + assignees: Assignee[] = assignees; + + overlappingRuleItems = [ + { value: 'sameResource', text: 'Allow across resources' }, + { value: 'allResources', text: 'Disallow all overlaps' }, + ]; + + assigneeIdEditorOptions = { + onValueChanged: (e: { value: unknown[]; component: { option: (k: string, v: unknown) => void } }) => { + if (e.value.length > 1) { + e.component.option('value', [e.value[e.value.length - 1]]); + } + }, + tagTemplate: 'assigneeTagTemplate', + }; + + popupOptions = { + onInitialized: (e: { component: dxPopup }) => { this.popup = e.component; }, + onHidden: () => { + this.setConflictError(false); + this.form?.updateData('assigneeId', []); + }, + }; + + formElementAttr = { id: 'form' }; + + private popup?: dxPopup; + + private form?: dxForm; + + showConflictError = false; + + private overlappingRule = 'sameResource'; + + constructor(service: Service) { + this.appointmentsData = service.getAppointments(); + } + + setConflictError(show: boolean): void { + this.showConflictError = show; + } + + onFormInitialized(e: DxFormTypes.InitializedEvent): void { + this.form = e.component; + this.form.on('fieldDataChanged', (event: { dataField: string }) => { + if (this.showConflictError && ['startDate', 'endDate', 'assigneeId', 'recurrenceRule'].includes(event.dataField)) { + this.setConflictError(false); + this.form.validate(); + } + }); + } + + customizeItem = (item: DxFormTypes.SimpleItem): void => { + if (item.name === 'allDayEditor' || item.name === 'recurrenceEndEditor') { + 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: () => !this.showConflictError, + }, + ]; + } + }; + + 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.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 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + + +
+ Loading... +
+ + diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/App.tsx b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/App.tsx new file mode 100644 index 000000000000..e45defb20c6a --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/App.tsx @@ -0,0 +1,270 @@ +import React, { + useCallback, useMemo, useRef, +} from 'react'; +import Scheduler, { + Form, Editing, Resource, Item, +} from 'devextreme-react/scheduler'; +import type { SchedulerTypes } from 'devextreme-react/scheduler'; +import SelectBox from 'devextreme-react/select-box'; +import type { SelectBoxTypes } from 'devextreme-react/select-box'; +import type { FormTypes } from 'devextreme-react/form'; +import type { PopupTypes } from 'devextreme-react/popup'; +import type { TagBoxTypes } from 'devextreme-react/tag-box'; +import { custom as customDialog } from 'devextreme/ui/dialog'; +import { data, assignees, type Appointment, type Assignee } from './data.ts'; + +type dxScheduler = NonNullable; +type dxForm = NonNullable; +type dxPopup = NonNullable; + +const currentDate = new Date(2026, 1, 10); +const views: SchedulerTypes.ViewType[] = ['day', 'week', 'workWeek', 'month']; + +const overlappingRuleItems = [ + { value: 'sameResource', text: 'Allow across resources' }, + { value: 'allResources', text: 'Disallow all overlaps' }, +]; + +function getNextDay(date: Date): Date { + const next = new Date(date); + next.setDate(next.getDate() + 1); + return next; +} + +function getEndDate(occurrence: SchedulerTypes.Occurrence): Date { + return (occurrence.appointmentData as Appointment).allDay + ? getNextDay(occurrence.startDate) + : occurrence.endDate; +} + +function isOverlapping( + a: SchedulerTypes.Occurrence, + b: SchedulerTypes.Occurrence, + overlappingRule: string, +): boolean { + const aEnd = getEndDate(a); + const bEnd = getEndDate(b); + if (a.startDate >= bEnd || b.startDate >= aEnd) return false; + if (overlappingRule === 'sameResource') { + return (a.appointmentData as Appointment).assigneeId[0] === (b.appointmentData as Appointment).assigneeId[0]; + } + return true; +} + +function detectConflict( + scheduler: dxScheduler, + newAppointment: Appointment, + overlappingRule: string, +): 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 = 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) => + isOverlapping(newOccurrence, existingOccurrence, overlappingRule), + ), + ); +} + +const assigneeIdEditorOptions = { + onValueChanged: (e: TagBoxTypes.ValueChangedEvent) => { + if (e.value.length > 1) { + e.component.option('value', [e.value[e.value.length - 1]]); + } + }, + tagRender: (itemData: Assignee) => tagTemplate(itemData), +}; + +const tagTemplate = (itemData: Assignee) => ( +
+ {itemData.text} +
+
+); + +const conflictInformerRender = () => ( +
This time slot conflicts with another appointment.
+); + +const App = () => { + const popupRef = useRef(null); + const formRef = useRef(null); + const showConflictErrorRef = useRef(false); + const overlappingRuleRef = useRef('sameResource'); + + const setConflictError = useCallback((show: boolean) => { + showConflictErrorRef.current = show; + formRef.current?.option('elementAttr.class', show ? '' : 'hide-informer'); + }, []); + + const alertConflictIfNeeded = useCallback(( + e: SchedulerTypes.AppointmentAddingEvent | SchedulerTypes.AppointmentUpdatingEvent, + appointmentData: Appointment, + ) => { + if (!detectConflict(e.component, appointmentData, overlappingRuleRef.current)) { + setConflictError(false); + return; + } + + e.cancel = true; + + if (popupRef.current?.option('visible')) { + setConflictError(true); + formRef.current?.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(); + } + }, [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 ( + <> + + + + +
+ + + + + + + + + + + + +
+
+ +
+
+ Overlapping Rule + { overlappingRuleRef.current = e.value; }} + /> +
+
+ + ); +}; + +export default App; diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/data.ts b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/data.ts new file mode 100644 index 000000000000..a1f288141c5d --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/data.ts @@ -0,0 +1,102 @@ +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' }, +]; + +export const data: 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], + }, +]; diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/index.html b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/index.html new file mode 100644 index 000000000000..ee451f8288ff --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/index.html @@ -0,0 +1,24 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/index.tsx b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/index.tsx new file mode 100644 index 000000000000..8acbec4b6179 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import App from './App.tsx'; + +ReactDOM.render( + , + document.getElementById('app'), +); diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/styles.css b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/styles.css new file mode 100644 index 000000000000..4dcecbdb8a0b --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/React/styles.css @@ -0,0 +1,66 @@ +.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; +} + +.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, +.dx-scheduler-form-main-group .dx-item:last-child .dx-item:last-child, +.dx-scheduler-form-main-group .dx-item:last-child .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/Demos/Scheduler/ResolveTimeConflicts/ReactJs/App.js b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/App.js new file mode 100644 index 000000000000..b85c079efcba --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/App.js @@ -0,0 +1,251 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import Scheduler, { + Form, Editing, Resource, Item, +} from 'devextreme-react/scheduler'; +import SelectBox from 'devextreme-react/select-box'; +import { custom as customDialog } from 'devextreme/ui/dialog'; +import { data, assignees } from './data.js'; + +const currentDate = new Date(2026, 1, 10); +const views = ['day', 'week', 'workWeek', 'month']; +const overlappingRuleItems = [ + { value: 'sameResource', text: 'Allow across resources' }, + { value: 'allResources', text: 'Disallow all overlaps' }, +]; +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, overlappingRule) { + 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(scheduler, newAppointment, overlappingRule) { + 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, overlappingRule), + ), + ); +} +const assigneeIdEditorOptions = { + onValueChanged: (e) => { + if (e.value.length > 1) { + e.component.option('value', [e.value[e.value.length - 1]]); + } + }, + tagRender: (itemData) => tagTemplate(itemData), +}; +const tagTemplate = (itemData) => ( +
+ {itemData.text} +
+
+); +const conflictInformerRender = () => ( +
This time slot conflicts with another appointment.
+); +const App = () => { + const popupRef = useRef(null); + const formRef = useRef(null); + const showConflictErrorRef = useRef(false); + const overlappingRuleRef = useRef('sameResource'); + const setConflictError = useCallback((show) => { + showConflictErrorRef.current = show; + formRef.current?.option('elementAttr.class', show ? '' : 'hide-informer'); + }, []); + const alertConflictIfNeeded = useCallback( + (e, appointmentData) => { + if (!detectConflict(e.component, appointmentData, overlappingRuleRef.current)) { + setConflictError(false); + return; + } + e.cancel = true; + if (popupRef.current?.option('visible')) { + setConflictError(true); + formRef.current?.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(); + } + }, + [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 ( + <> + + + + +
+ + + + + + + + + + + + +
+
+ +
+
+ Overlapping Rule + { + overlappingRuleRef.current = e.value; + }} + /> +
+
+ + ); +}; +export default App; diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/data.js b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/data.js new file mode 100644 index 000000000000..2e1e037aedfe --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/data.js @@ -0,0 +1,85 @@ +export const assignees = [ + { 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' }, +]; +export const data = [ + { + 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], + }, +]; diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/index.html b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/index.html new file mode 100644 index 000000000000..db31b0fd60c6 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/index.html @@ -0,0 +1,44 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/index.js b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/index.js new file mode 100644 index 000000000000..b853e0be8242 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/index.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App.js'; + +ReactDOM.render(, document.getElementById('app')); diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/styles.css b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/styles.css new file mode 100644 index 000000000000..4dcecbdb8a0b --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/ReactJs/styles.css @@ -0,0 +1,66 @@ +.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; +} + +.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, +.dx-scheduler-form-main-group .dx-item:last-child .dx-item:last-child, +.dx-scheduler-form-main-group .dx-item:last-child .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/Demos/Scheduler/ResolveTimeConflicts/Vue/App.vue b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/App.vue new file mode 100644 index 000000000000..a7e6001aebd1 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/App.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/data.ts b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/data.ts new file mode 100644 index 000000000000..a1f288141c5d --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/data.ts @@ -0,0 +1,102 @@ +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' }, +]; + +export const data: 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], + }, +]; diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/index.html b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/index.html new file mode 100644 index 000000000000..c3ac7d2ffbef --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/index.html @@ -0,0 +1,29 @@ + + + + DevExtreme Demo + + + + + + + + + + + + + + +
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/index.ts b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/index.ts new file mode 100644 index 000000000000..684d04215d72 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/Vue/index.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue'; +import App from './App.vue'; + +createApp(App).mount('#app'); diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/data.js b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/data.js new file mode 100644 index 000000000000..65546ac13a93 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/data.js @@ -0,0 +1,86 @@ +const assignees = [ + { 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 data = [ + { + 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], + }, +]; diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/index.html b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/index.html new file mode 100644 index 000000000000..515568bf97e7 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/index.html @@ -0,0 +1,27 @@ + + + + DevExtreme Demo + + + + + + + + + + + + +
+
+
+
+ Overlapping Rule +
+
+
+
+ + diff --git a/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/index.js b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/index.js new file mode 100644 index 000000000000..374a615d9f16 --- /dev/null +++ b/apps/demos/Demos/Scheduler/ResolveTimeConflicts/jQuery/index.js @@ -0,0 +1,212 @@ +$(() => { + let popup; + let form; + let showConflictError = false; + let overlappingRule = 'sameResource'; + + const scheduler = $('#scheduler').dxScheduler({ + dataSource: data, + views: ['day', 'week', 'workWeek', 'month'], + currentView: 'week', + currentDate: new Date(2026, 1, 10), + startDayHour: 9, + endDayHour: 19, + height: 600, + showAllDayPanel: false, + allDayPanelMode: 'hidden', + resources: [{ + fieldExpr: 'assigneeId', + dataSource: assignees, + valueExpr: 'id', + colorExpr: 'color', + icon: 'user', + allowMultiple: true, + }], + editing: { + popup: { + onInitialized: (e) => { + popup = e.component; + }, + onHidden: () => { + setConflictError(false); + form?.updateData('assigneeId', []); + }, + }, + form: { + labelMode: 'hidden', + elementAttr: { class: 'hide-informer', id: 'form' }, + onInitialized: (e) => { + form = e.component; + + form.on('fieldDataChanged', (e) => { + if (showConflictError && ['startDate', 'endDate', 'assigneeId', 'recurrenceRule'].includes(e.dataField)) { + setConflictError(false); + form.validate(); + } + }); + }, + items: [ + { + name: 'conflictInformer', + template: () => $('
') + .addClass('conflict-informer') + .text('This time slot conflicts with another appointment.'), + }, + { + name: 'mainGroup', + items: [ + 'subjectGroup', + 'dateGroup', + 'repeatGroup', + { + name: 'assigneeIdGroup', + items: [ + 'assigneeIdIcon', + { + name: 'assigneeId', + isRequired: true, + editorOptions: { + onValueChanged: (e) => { + if (e.value.length > 1) { + e.component.option('value', [e.value[e.value.length - 1]]); + } + }, + tagTemplate: (tagData) => $('
') + .css('background-color', tagData.color) + .css('border-color', tagData.color) + .addClass('dx-tag-content') + .append( + $('').text(tagData.text), + $('
').addClass('dx-tag-remove-button'), + ), + }, + }, + ], + }, + ], + }, + 'recurrenceGroup', + ], + customizeItem: (item) => { + 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: () => !showConflictError, + }, + ]; + } + }, + }, + }, + onAppointmentAdding(e) { + alertConflictIfNeeded(e, e.appointmentData); + }, + onAppointmentUpdating(e) { + alertConflictIfNeeded(e, e.newData); + }, + }).dxScheduler('instance'); + + function setConflictError(show) { + showConflictError = show; + form?.option('elementAttr.class', show ? '' : 'hide-informer'); + } + + function alertConflictIfNeeded(e, appointmentData) { + if (!detectConflict(appointmentData)) { + setConflictError(false); + return; + } + + e.cancel = true; + + if (popup?.option('visible')) { + setConflictError(true); + form.validate(); + } else { + const dialog = DevExpress.ui.dialog.custom({ + showTitle: false, + messageHtml: '

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