Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ZMS-3253 ZMS-3466 ZMS-3415 ZMS-1891): Replicate frontend validation in the backend for the availability opening hours to improve data integrity and frontend validation messaging #799

Conversation

ThomasAFink
Copy link
Member

@ThomasAFink ThomasAFink commented Jan 23, 2025

Pull Request Checklist (Feature Branch to next):

  • Ich habe die neuesten Änderungen aus dem next Branch in meinen Feature-Branch gemergt.
  • Das Code-Review wurde abgeschlossen.
  • Fachliche Tests wurden durchgeführt und sind abgeschlossen.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced availability conflict detection and handling.
    • Improved validation for availability entries.
    • More robust error messaging for availability-related operations.
    • Added user-friendly error templates for failed availability additions and updates.
    • Introduced new methods for validating inputs and extracting date ranges.
  • Bug Fixes

    • Corrected slot time calculations.
    • Fixed overlapping availability detection.
    • Improved input validation for availability entries.
  • Improvements

    • Refined error handling for availability add and update processes.
    • Enhanced user feedback for conflicts and validation errors.
    • Updated default slot time from 12 to 10 minutes.
  • Technical Enhancements

    • Added new validation methods for availability entities.
    • Improved type safety in PHP methods.
    • Streamlined conflict resolution logic.
    • Enhanced logic for managing existing availability overlaps.

These changes focus on improving the reliability and user experience of availability management, with particular emphasis on conflict detection and error handling.

Tom Fink added 30 commits November 12, 2024 12:25
@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's how to fix the update button not being re-enabled:

handleChange(data) {
    if (data.__modified) {
        clearTimeout(this.timer);
        
        // Check if values match original state to reset modified flag
        const originalData = this.props.data;
        const isUnchanged = originalData && 
            originalData.startTime === data.startTime &&
            originalData.endTime === data.endTime &&
            originalData.startDate === data.startDate &&
            originalData.endDate === data.endDate;
        
        if (isUnchanged) {
            data.__modified = false;
        }
        
        // Update state first
        this.setState(
            Object.assign({}, updateAvailabilityInState(this.state, data)),
            () => {
                // Immediately check for conflicts and validation errors
                if (data.tempId || data.id) {
                    Promise.all([
                        this.getConflictList(),
                        this.getValidationList()
                    ]).then(() => {
                        // If no conflicts and form matches original, ensure modified is false
                        if (isUnchanged && 
                            (!this.state.conflictList?.conflictIdList?.length) && 
                            (!this.state.errorList?.length)) {
                            this.setState(state => ({
                                availabilitylist: state.availabilitylist.map(a => 
                                    a.id === data.id ? {...a, __modified: false} : a
                                )
                            }));
                        }
                    });
                }
                
                // Calculate slots after validation
                this.readCalculatedAvailabilityList();
            }
        );
    }
    if (data.kind && inArray(data.kind, ["origin", "future", "exclusion"])) {
        this.handleChangesAvailabilityExclusion(data);
    }
}

Key changes:

  1. Check if form values match original state
  2. Reset __modified flag when values are unchanged
  3. After conflict check, ensure __modified is false if no conflicts exist and form matches original
  4. Use Promise.all to ensure both validation and conflict checks complete before resetting state

This will properly re-enable the update button when conflicts are resolved and the form matches its original state.


✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T17:15:12.124Z
Learning: In the eappointment system's handleChange method, reset the __modified flag when form values match the original state and there are no conflicts/errors to properly re-enable update buttons after conflict resolution.

🧠 Learnings used
Learnt from: ThomasAFink
PR: it-at-m/eappointment#0
File: :0-0
Timestamp: 2025-01-27T17:21:52.671Z
Learning: In the eappointment system's FormButtons component, the update button's disabled state should not depend on data.__modified when checking for conflicts/errors, as this prevents re-enabling the button when conflicts are resolved. Only use __modified for new availabilities.

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for FormButtons.js:

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError 
    } = props;

    // For existing availabilities, only check conflicts/errors
    // For new availabilities, also check modified flag
    const updateDisabled = (!data.id && data.__modified) || 
        hasConflicts || 
        hasValidationErrors || 
        hasSlotCountError || 
        props.isCreatingExclusion;

    // Keep other buttons' logic unchanged
    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

This change ensures that for existing availabilities (with an id), the update button is only disabled by conflicts/errors, not by the __modified flag. For new availabilities (without an id), it still checks the __modified flag.


🧠 Learnings used
Learnt from: ThomasAFink
PR: it-at-m/eappointment#0
File: :0-0
Timestamp: 2025-01-27T17:24:32.391Z
Learning: In the eappointment system's FormButtons component, the update button should require both data.__modified=true AND no conflicts/errors to be enabled. This ensures proper state transitions: disabled by default -> enabled on valid changes -> disabled on conflicts -> re-enabled when conflicts resolved.

Learnt from: ThomasAFink
PR: it-at-m/eappointment#0
File: :0-0
Timestamp: 2025-01-27T17:21:52.671Z
Learning: In the eappointment system's FormButtons component, the update button's disabled state should not depend on data.__modified when checking for conflicts/errors, as this prevents re-enabling the button when conflicts are resolved. Only use __modified for new availabilities.

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for FormButtons.js:

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        stateChanged,
        isCreatingExclusion
    } = props;

    // Simplify disabled logic to match FooterButtons
    const disabled = !data.id || hasConflicts || hasSlotCountError;
    const updateDisabled = !data.id || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

FormButtons.propTypes = {
    data: PropTypes.object,
    hasConflicts: PropTypes.bool,
    hasValidationErrors: PropTypes.bool,
    hasSlotCountError: PropTypes.bool,
    stateChanged: PropTypes.bool,
    onCopy: PropTypes.func,
    onExclusion: PropTypes.func,
    onEditInFuture: PropTypes.func,
    onDelete: PropTypes.func,
    onUpdateSingle: PropTypes.func,
    selectedDate: PropTypes.number,
    isCreatingExclusion: PropTypes.bool
}

And update AvailabilityForm to pass stateChanged:

render() {
    const { data } = this.state
    const onChange = (name, value) => {
        this.handleChange(name, value)
    }

    const hasConflicts = this.props.conflictList.conflictIdList && 
        this.props.conflictList.conflictIdList.length > 0;
    
    const hasValidationErrors = this.props.errorList &&
        Object.keys(this.props.errorList).length > 0;

    return (
        <div>
            {<FormContent 
                today = {this.props.today} 
                availabilityList={this.props.availabilityList}
                setErrorRef={this.props.setErrorRef}
                errorList={this.props.errorList}
                conflictList={this.props.conflictList}
                {... { data, onChange }} />}
            {<FormButtons 
                data = {data}
                onCopy={this.props.onCopy} 
                onExclusion={this.props.onExclusion}
                onEditInFuture={this.props.onEditInFuture} 
                onDelete={this.props.onDelete}
                onUpdateSingle={this.props.onUpdateSingle}
                selectedDate={this.props.selectedDate}
                hasConflicts={hasConflicts}
                hasValidationErrors={hasValidationErrors}
                hasSlotCountError={hasSlotCountError(this.props)}
                isCreatingExclusion={this.props.isCreatingExclusion}
                stateChanged={data.__modified}
            />}
        </div>
    )   
}

This simplifies the logic to match FooterButtons.js and should fix the button state issues.


✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-28T09:29:11.316Z
Learning: In the eappointment system's FormButtons component, avoid complex state tracking with __original comparisons. Instead, use simple flags like stateChanged and hasConflicts directly to manage button states, similar to FooterButtons component.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: ThomasAFink
PR: it-at-m/eappointment#0
File: :0-0
Timestamp: 2025-01-27T17:24:32.391Z
Learning: In the eappointment system's FormButtons component, the update button should require both data.__modified=true AND no conflicts/errors to be enabled. This ensures proper state transitions: disabled by default -> enabled on valid changes -> disabled on conflicts -> re-enabled when conflicts resolved.

@ThomasAFink
Copy link
Member Author

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for FormButtons.js:

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        stateChanged,
        isCreatingExclusion
    } = props;

    // Simplify disabled logic to match FooterButtons
    const disabled = !data.id || hasConflicts || hasSlotCountError;
    const updateDisabled = !data.id || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

FormButtons.propTypes = {
    data: PropTypes.object,
    hasConflicts: PropTypes.bool,
    hasValidationErrors: PropTypes.bool,
    hasSlotCountError: PropTypes.bool,
    stateChanged: PropTypes.bool,
    onCopy: PropTypes.func,
    onExclusion: PropTypes.func,
    onEditInFuture: PropTypes.func,
    onDelete: PropTypes.func,
    onUpdateSingle: PropTypes.func,
    selectedDate: PropTypes.number,
    isCreatingExclusion: PropTypes.bool
}

And update AvailabilityForm to pass stateChanged:

render() {
    const { data } = this.state
    const onChange = (name, value) => {
        this.handleChange(name, value)
    }

    const hasConflicts = this.props.conflictList.conflictIdList && 
        this.props.conflictList.conflictIdList.length > 0;
    
    const hasValidationErrors = this.props.errorList &&
        Object.keys(this.props.errorList).length > 0;

    return (
        <div>
            {<FormContent 
                today = {this.props.today} 
                availabilityList={this.props.availabilityList}
                setErrorRef={this.props.setErrorRef}
                errorList={this.props.errorList}
                conflictList={this.props.conflictList}
                {... { data, onChange }} />}
            {<FormButtons 
                data = {data}
                onCopy={this.props.onCopy} 
                onExclusion={this.props.onExclusion}
                onEditInFuture={this.props.onEditInFuture} 
                onDelete={this.props.onDelete}
                onUpdateSingle={this.props.onUpdateSingle}
                selectedDate={this.props.selectedDate}
                hasConflicts={hasConflicts}
                hasValidationErrors={hasValidationErrors}
                hasSlotCountError={hasSlotCountError(this.props)}
                isCreatingExclusion={this.props.isCreatingExclusion}
                stateChanged={data.__modified}
            />}
        </div>
    )   
}

This simplifies the logic to match FooterButtons.js and should fix the button state issues.

✏️ Learnings added
🧠 Learnings used

@coderabbitai doesn't work now

                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>

is always enabled

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for FormButtons.js:

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        isCreatingExclusion
    } = props;

    // For existing availabilities, require changes and no errors
    const updateDisabled = !data.id || !data.__modified || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    // Keep other buttons' logic unchanged
    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

The key change is in the updateDisabled logic - now it requires data.__modified to be true (indicating changes) and no conflicts/errors to be enabled. This matches the behavior of FooterButtons while working correctly for individual availabilities.

(`・ω・´)ゞ thoughtfully considering button states


✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-28T09:35:12.124Z
Learning: In the eappointment system's FormButtons component, the update button should be enabled only when data.__modified is true AND there are no conflicts/errors, similar to FooterButtons. This ensures proper state management for individual availabilities.

@ThomasAFink
Copy link
Member Author

ThomasAFink commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for FormButtons.js:

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        isCreatingExclusion
    } = props;

    // For existing availabilities, require changes and no errors
    const updateDisabled = !data.id || !data.__modified || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    // Keep other buttons' logic unchanged
    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

The key change is in the updateDisabled logic - now it requires data.__modified to be true (indicating changes) and no conflicts/errors to be enabled. This matches the behavior of FooterButtons while working correctly for individual availabilities.

(`・ω・´)ゞ thoughtfully considering button states

✏️ Learnings added

@coderabbitai doesn't work it's still always enabled. The updateSingle should be disabled whenever there are conflicts or validation errors and enabled whenever there are not. Here's my code:

import React from 'react'
import PropTypes from 'prop-types'

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        isCreatingExclusion
    } = props;

    // For existing availabilities, require changes and no errors
    const updateDisabled = !data.id || !data.__modified || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    // Keep other buttons' logic unchanged
    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

FormButtons.propTypes = {
    data: PropTypes.object,
    hasConflicts: PropTypes.bool,
    hasValidationErrors: PropTypes.bool,
    hasSlotCountError: PropTypes.bool,
    stateChanged: PropTypes.bool,
    onCopy: PropTypes.func,
    onExclusion: PropTypes.func,
    onEditInFuture: PropTypes.func,
    onDelete: PropTypes.func,
    onUpdateSingle: PropTypes.func,
    selectedDate: PropTypes.number,
    isCreatingExclusion: PropTypes.bool
}

export default FormButtons
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import FormButtons from './formButtons'
import FormContent from './content'
import { getDataValuesFromForm, cleanupFormData, getFormValuesFromData } from '../helpers'
import { hasSlotCountError } from '../form/validate';

class AvailabilityForm extends Component {
    constructor(props) {
        super(props);
        this.state = {
            data: getFormValuesFromData(this.props.data)
        };
    }

    componentDidUpdate(prevProps) {
        if (this.props.data && this.props.data !== prevProps.data) {
            this.setState({
                data: getFormValuesFromData(this.props.data)
            })
        }
    }

    handleChange(name, value) {
        this.setState((state) => ({
            data: cleanupFormData(Object.assign({}, state.data, {
                [name]: value,
                __modified: true
            }))
        }), () => {
            this.props.handleChange(getDataValuesFromForm(this.state.data, this.props.data.scope))
        })
    }

    render() {
        const { data } = this.state
        const onChange = (name, value) => {
            this.handleChange(name, value)
        }
    
        const hasConflicts = this.props.conflictList.conflictIdList && 
            this.props.conflictList.conflictIdList.length > 0;
        
        const hasValidationErrors = this.props.errorList &&
            Object.keys(this.props.errorList).length > 0;
    
        return (
            <div>
                {<FormContent 
                    today = {this.props.today} 
                    availabilityList={this.props.availabilityList}
                    setErrorRef={this.props.setErrorRef}
                    errorList={this.props.errorList}
                    conflictList={this.props.conflictList}
                    {... { data, onChange }} />}
                {<FormButtons 
                    data = {data}
                    onCopy={this.props.onCopy} 
                    onExclusion={this.props.onExclusion}
                    onEditInFuture={this.props.onEditInFuture} 
                    onDelete={this.props.onDelete}
                    onUpdateSingle={this.props.onUpdateSingle}
                    selectedDate={this.props.selectedDate}
                    hasConflicts={hasConflicts}
                    hasValidationErrors={hasValidationErrors}
                    hasSlotCountError={hasSlotCountError(this.props)}
                    isCreatingExclusion={this.props.isCreatingExclusion}
                    stateChanged={data.__modified}
                />}
            </div>
        )   
    }
}

AvailabilityForm.defaultProps = {
    data: {},
    handleChange: () => { },
    onCopy: () => { },
    onExclusion: () => { },
    onEditInFuture: () => { }
}

AvailabilityForm.propTypes = {
    availabilityList: PropTypes.array,
    errorList: PropTypes.object,
    conflictList: PropTypes.object,
    data: PropTypes.object,
    today: PropTypes.number,
    selectedDate: PropTypes.number,
    handleChange: PropTypes.func,
    onCopy: PropTypes.func,
    onExclusion: PropTypes.func,
    onEditInFuture: PropTypes.func,
    setErrorRef: PropTypes.func,
    onDelete: PropTypes.func,
    onUpdateSingle: PropTypes.func,
    isCreatingExclusion: PropTypes.bool
}

export default AvailabilityForm
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import $ from 'jquery'
import moment from 'moment'
import validate from './form/validate'
import Conflicts from './conflicts'
import TabsBar from './tabsbar'
import GraphView from './timetable/graphview.js'
import TableView from './timetable/tableview.js'
import SaveBar from './saveBar'
import AccordionLayout from './layouts/accordion'
import PageLayout from './layouts/page'
import { inArray, showSpinner, hideSpinner } from '../../lib/utils'
import ExceptionHandler from '../../lib/exceptionHandler';

import {
    getInitialState,
    getStateFromProps,
    writeSlotCalculationIntoAvailability,
    getNewAvailability,
    mergeAvailabilityListIntoState,
    updateAvailabilityInState,
    cleanupAvailabilityForSave,
    deleteAvailabilityInState,
    findAvailabilityInStateByKind,
    formatTimestampDate
} from "./helpers"

const tempId = (() => {
    let lastId = -1

    return () => {
        lastId += 1
        return `__temp__${lastId}`
    }
})()

class AvailabilityPage extends Component {
    constructor(props) {
        super(props)
        this.state = getInitialState(props);
        errorMessage: null;
        this.waitintervall = 1000;
        this.errorElement = null;
        this.successElement = null;
        this.isCreatingExclusion = false
        this.setErrorRef = element => {
            this.errorElement = element
        };
        this.setSuccessRef = element => {
            this.successElement = element
        };
    }

    handleScrollToBottom() {
        window.scrollTo(0, document.body.scrollHeight);
    }

    componentDidMount() {
        this.getValidationList()
        this.unloadHandler = ev => {
            const confirmMessage = "Es wurden nicht alle Änderungen gespeichert. Diese gehen beim schließen verloren."
            if (this.state.stateChanged) {
                ev.returnValue = confirmMessage
                return confirmMessage
            }
        }

        window.addEventListener('beforeunload', this.unloadHandler)
    }

    componentDidUnMount() {
        window.removeEventListener('beforeunload', this.unloadHandler)
    }

    onPublishAvailability() {
        this.getValidationList();
        this.getConflictList();
        let state = {};
        state = { selectedAvailability: null }
        this.setState(state, () => {
            this.onSaveUpdates();
        });
    }

    refreshData() {
        const currentDate = formatTimestampDate(this.props.timestamp)
        const url = `${this.props.links.includeurl}/scope/${this.props.scope.id}/availability/day/${currentDate}/conflicts/`
        $.ajax(url, {
            method: 'GET'
        }).done(data => {
            const newProps = {
                conflicts: data.conflicts,
                availabilitylist: data.availabilityList,
                busyslots: data.busySlotsForAvailabilities,
                maxslots: data.maxSlotsForAvailabilities,
                slotbuckets: data.slotBuckets,
            }
            this.setState(Object.assign({}, getStateFromProps(Object.assign({}, this.props, newProps)), {
                stateChanged: false
            }))

        }).fail(err => {
            console.log('refreshData error', err)
        })
    }

    onSaveUpdates() {
        const ok = confirm('Möchten Sie wirklich die Änderungen aller Öffnungszeiten speichern?');
        if (ok) {
            showSpinner();
    
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendData = this.state.availabilitylist
                .filter((availability) => {
                    return (
                        (availability.__modified || 
                        (availability.tempId && availability.tempId.includes('__temp__'))) &&
                        !this.hasErrors(availability)
                    );
                })
                .map(availability => {
                    const sendAvailability = Object.assign({}, availability);
                    if (availability.tempId) {
                        delete sendAvailability.tempId;
                    }
                    console.log(availability.kind);
                    return {
                        ...sendAvailability,
                        kind: availability.kind || 'default',
                    };
                })
                .map(cleanupAvailabilityForSave);
    
            const payload = {
                availabilityList: sendData,
                selectedDate: selectedDate
            };

            console.log('Saving updates', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((success) => {
                console.log('save success:', success);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail((err) => {
                let isException = err.responseText.toLowerCase().includes('exception');
                let errorMessage = '';
                
                // Format validation errors
                if (this.state.errorList && this.state.errorList.length > 0) {
                    errorMessage = 'Folgende Fehler sind bei der Prüfung Ihrer Eingaben aufgetreten:<br />';
                    errorMessage += this.state.errorList.map(error => error.message).join('<br />');
                }
                
                // Format conflict errors - this contains the properly formatted messages with dates/times
                if (this.state.conflictList && this.state.conflictList.itemList) {
                    const conflicts = Object.values(this.state.conflictList.itemList);
                    if (conflicts.length > 0) {
                        errorMessage += (errorMessage ? '<br /><br />' : '');
                        errorMessage += 'Folgende Zeiten führen mit der aktuellen Auswahl zu Konflikten:<br />';
                        errorMessage += conflicts.join('<br />');
                    }
                }
                
                // Only parse HTML error if no other errors exist
                if (err.responseText && !errorMessage) {
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(err.responseText, 'text/html');
                    const errorDiv = doc.querySelector('.message__body.message--error');
                    
                    if (errorDiv) {
                        const heading = errorDiv.querySelector('h3')?.textContent.trim();
                        // Don't show the generic error message
                        if (heading) errorMessage = heading;
                        
                        // Get conflicts after receiving error
                        this.getConflictList();
                    }
                }
            
                // Handle exception separately
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else if (err.status === 404) {
                    console.log('404 error, ignored');
                } else {
                    console.log('save all error', err);
                }
            
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                    errorMessage: errorMessage || 'Ein unbekannter Fehler ist aufgetreten'
                });
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }
    
    onRevertUpdates() {
        this.isCreatingExclusion = false
        this.setState(Object.assign({}, getInitialState(this.props), {
            selectedTab: this.state.selectedTab
        }), () => {
            this.refreshData()
            this.getValidationList()
        })
    }

    onUpdateSingleAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich aktualisiert werden?');
        const id = availability.id;
    
        if (ok) {
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendAvailability = Object.assign({}, availability);
    
            if (sendAvailability.tempId) {
                delete sendAvailability.tempId;
            }
    
            const payload = {
                availabilityList: [
                    {
                        ...cleanupAvailabilityForSave(sendAvailability),
                        kind: availability.kind || 'default'
                    }
                ],
                selectedDate: selectedDate
            };
    
            console.log('Updating single availability', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/save/${id}/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((data) => {
                console.log('Single update success:', data);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail(err => {
                const isException = err.responseText.toLowerCase().includes('exception');
                if (isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('Update error:', err);
                }
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                });
    
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }

    onDeleteAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich gelöscht werden?')
        const id = availability.id
        if (ok) {
            $.ajax(`${this.props.links.includeurl}/availability/delete/${id}/`, {
                method: 'GET'
            }).done(() => {
                this.setState(Object.assign({}, deleteAvailabilityInState(this.state, availability), {
                    selectedAvailability: null
                }), () => {
                    this.refreshData()
                    this.getConflictList(),
                    this.getValidationList()
                });
                hideSpinner();
            }).fail(err => {
                console.log('delete error', err);
                let isException = err.responseText.toLowerCase().includes('exception');
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('delete error', err);
                }
                hideSpinner();
            })
        } else {
            hideSpinner();
        }        
    }

    onCopyAvailability(availability) {
        const start = formatTimestampDate(availability.startDate)
        const end = formatTimestampDate(availability.endDate)

        let copysourcetitle = (availability.description) ? availability.description : `${start} - ${end}`;
        const copyAvailability = Object.assign({}, availability, {
            tempId: tempId(),
            id: null,
            description: `Kopie von ${copysourcetitle}`
        })
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [copyAvailability]),
            { selectedAvailability: copyAvailability, stateChanged: true }
        ), () => {
            this.getValidationList()
        })
    }

    onSelectAvailability(availability) {
        if (availability || ! this.state.selectedAvailability) {
            this.setState({
                selectedAvailability: availability
            }, () => {
                this.getValidationList()
            })
        } else {
            this.setState({
                selectedAvailability: null
            })
        }
        
    }

    editExclusionAvailability(availability, startDate, endDate, description, kind) {
        (startDate) ? availability.startDate = startDate : null;
        (endDate) ? availability.endDate = endDate : null;
        availability.__modified = true;
        if (! availability.kind && kind != 'origin') {
            availability.tempId = tempId()
            availability.id = null
            availability.description = (description) ? description : availability.description
            availability.kind = kind
        } else {
            availability.kind = 'origin'
        }
        return availability;
    }

    onCreateExclusionForAvailability(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        const tomorrow = selectedDay.clone().add(1, 'days')

        this.isCreatingExclusion = true;

        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        let name = availability.description;
        name = name.replaceAll('Ausnahme zu Terminserie ', '');
        name = name.replaceAll('Fortführung der Terminserie ', '');

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        if (availability.startDate === selectedDay.unix()) {
            originAvailability.description = `Ausnahme zu Terminserie ` + name;
        }

        let exclusionAvailability = originAvailability;
        if (originAvailability.startDate < selectedDay.unix()) {
            exclusionAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10), 
                parseInt(selectedDay.unix(), 10),
                `Ausnahme zu Terminserie ` + name,
                'exclusion'
            )
        }

        let futureAvailability = originAvailability;
        if (parseInt(tomorrow.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(tomorrow.unix(), 10),
                null,
                `Fortführung der Terminserie ` + name,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                exclusionAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: exclusionAvailability, 
                stateChanged: true 
            }
        ), () => {
            console.log('in after merging', this.state.availabilitylist);
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onEditAvailabilityInFuture(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        let futureAvailability = originAvailability;
        if (parseInt(selectedDay.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10),
                null,
                `Änderung ab ${formatTimestampDate(selectedDay)} (${availability.id})`,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: futureAvailability, 
                stateChanged: true 
            }
        ), () => {
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onNewAvailability() {
        let state = {};
        const newAvailability = getNewAvailability(this.props.timestamp, tempId(), this.props.scope);
        newAvailability.type = "appointment";
        
        state = Object.assign(
            state, 
            updateAvailabilityInState(this.state, newAvailability)
        );
        
        state.selectedAvailability = newAvailability;
        state.stateChanged = true;
        
        this.setState(state, () => {
            Promise.all([
                this.getValidationList(),
                this.getConflictList()
            ]).then(() => {
                this.setState({ selectedAvailability: null });
            });
            
            $('body').scrollTop(0);
        });
    }

    onTabSelect(tab) {
        this.setState({ selectedTab: tab.component });
    }

    onConflictedIdSelect(id) {
        const availability = this.state.availabilitylist.filter(availability => availability.id === id)[0]
        if (availability) {
            this.setState({ selectedAvailability: availability })
        }
    }

    hasErrors(availability) {
        let hasError = false;
        let hasConflict = false;

        if (this.state.errorList) {
            Object.values(this.state.errorList).forEach(errorItem => {
                if (availability.id === errorItem.id)
                    hasError = true;
            });
        }

        if (this.state.conflictList && this.state.conflictList.conflictIdList) {
            this.state.conflictList.conflictIdList.forEach(id => {
                if (availability.id === id)
                    hasConflict = true;
            });
        }

        return hasError || hasConflict;
    }

    getValidationList() {
        return new Promise((resolve, reject) => {
            const validateData = (data) => {
                const validationResult = validate(data, this.props);
                if (!validationResult.valid) {
                    return validationResult.errorList;
                }
                return [];
            };
    
            const list = this.state.availabilitylist
                .map(validateData)
                .flat();
    
            console.log("Validation list:", list);
    
            this.setState(
                {
                    errorList: list.length ? list : [],
                },
                () => {
                    if (list.length > 0) {
                        console.warn("Validation failed with errors:", list);
                        this.errorElement?.scrollIntoView();
                    } else {
                        console.log("Validation passed.");
                        resolve();
                    }
                }
            );
        });
    }
    
    validateAvailabilityList(availabilitylist) {
        const timeRegex = /^\d{2}:\d{2}(:\d{2})?$/;
    
        const isValidTimestamp = (timestamp) => !Number.isNaN(Number(timestamp)) && moment.unix(timestamp).isValid();
    
        const invalidAvailabilities = availabilitylist.filter((availability) => {
            const hasInvalidDates =
                !isValidTimestamp(availability.startDate) || !isValidTimestamp(availability.endDate);
            const hasInvalidTimes =
                !timeRegex.test(availability.startTime) || !timeRegex.test(availability.endTime);
    
            if (hasInvalidDates || hasInvalidTimes) {
                console.warn("Invalid availability detected:", availability);
            }
    
            return hasInvalidDates || hasInvalidTimes;
        });
    
        return invalidAvailabilities;
    }

    getConflictList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, selectedAvailability } = this.state;
                const { timestamp } = this.props;
                if (selectedAvailability.kind === 'exclusion') {
                    console.log("Skipping exclusion..")
                    return;
                }
                const nonExclusionAvailabilities = availabilitylist.filter(a => a.kind !== 'exclusion');

                selectedAvailability.startTime = moment(selectedAvailability.startTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');
                selectedAvailability.endTime = moment(selectedAvailability.endTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');

                const requestOptions = {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        availabilityList: nonExclusionAvailabilities,
                        selectedDate: formatTimestampDate(timestamp),
                        selectedAvailability,
                    }),
                };

                const url = `${this.props.links.includeurl}/availability/conflicts/`;

                fetch(url, requestOptions)
                    .then((res) => res.json())
                    .then(
                        (data) => {
                            console.log("Conflicts fetched successfully:", data);
                            this.setState({
                                conflictList: {
                                    itemList: { ...data.conflictList },
                                    conflictIdList: data.conflictIdList,
                                },
                            });
                            if (data.conflictIdList.length > 0) {
                                this.errorElement?.scrollIntoView();
                            }
                        },
                        (err) => {
                            console.error("Conflict fetch error:", err);
                            hideSpinner();
                        }
                    );
            })
            .catch((error) => {
                console.warn("Validation failed. Conflict fetch aborted.", error);
            });
    }
    
    renderTimeTable() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const ViewComponent = this.state.selectedTab == 'graph' ? GraphView : TableView;
        return <ViewComponent
            timestamp={this.props.timestamp}
            conflicts={this.state.conflicts}
            availabilityList={this.state.availabilitylistslices || this.state.availabilitylist}
            data={this.state.selectedAvailability}
            maxWorkstationCount={this.state.maxWorkstationCount || this.props.maxworkstationcount}
            links={this.props.links}
            onSelect={onSelect}
            onDelete={onDelete}
            onAbort={this.onRevertUpdates.bind(this)}
            slotBuckets={this.state.slotbuckets}
        />
    }

    readCalculatedAvailabilityList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, busyslots } = this.state;
    
                console.log("Validation passed. Proceeding with /availability/slots/.");
    
                $.ajax(`${this.props.links.includeurl}/availability/slots/`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    data: JSON.stringify({
                        availabilityList: availabilitylist,
                        busySlots: busyslots,
                    }),
                })
                    .done((responseData) => {
                        console.log("Slots fetched successfully:", responseData);
                        const availabilityList = writeSlotCalculationIntoAvailability(
                            this.state.availabilitylist,
                            responseData['maxSlots'],
                            responseData['busySlots']
                        );
                        this.setState({
                            availabilitylistslices: availabilityList,
                            maxWorkstationCount: parseInt(responseData['maxWorkstationCount']),
                        });
                    })
                    .fail((err) => {
                        console.error("Error during /availability/slots/ fetch:", err);
                        if (err.status === 404) {
                            console.log("404 error ignored.");
                            hideSpinner();
                        } else {
                            const isException = err.responseText.toLowerCase().includes("exception");
                            if (err.status >= 500 && isException) {
                                new ExceptionHandler($(".opened"), {
                                    code: err.status,
                                    message: err.responseText,
                                });
                            } else {
                                console.error("Unexpected error:", err.responseText);
                            }
                            hideSpinner();
                        }
                    });
            })
            .catch((error) => {
                console.warn("Validation failed. Slot calculation fetch aborted.", error);
                this.setState({ errorList: error });
                this.errorElement?.scrollIntoView();
            });
    }    
    
    handleChange(data) {
        if (data.__modified) {
            clearTimeout(this.timer)
            this.setState(
                Object.assign({}, updateAvailabilityInState(this.state, data)),
                () => {
                    this.readCalculatedAvailabilityList();
                    if (data.tempId || data.id) {
                        this.timer = setTimeout(() => {
                            this.getConflictList()
                            this.getValidationList()
                        }, this.waitintervall)
                    }
                }
            );
        }
        if (data.kind && inArray(data.kind, ["origin", "future", "exclusion"])) {
            this.handleChangesAvailabilityExclusion(data)
        }
    }

    handleChangesAvailabilityExclusion(data) {
        if ('origin' == data.kind && data.__modified) {
            this.handleOriginChanges(data)
        }
        if ('exclusion' == data.kind && data.__modified) {
            this.handleExclusionChanges(data)        
        }
        if ('future' == data.kind && data.__modified) {
            this.handleFutureChanges(data)
        }
    }

    handleOriginChanges(data) {
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: moment(data.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (exclusionAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                exclusionAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        }) : data;
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()
        });
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [exclusionAvailability, futureAvailability, data])
        ));          
    }

    handleExclusionChanges(data) {
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');

        const exclusionAvailability = Object.assign({}, data, {
            endDate: (data.startDate > data.endDate) ? data.startDate : data.endDate
        });

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        });

        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability, 
                futureAvailability, 
                exclusionAvailability
            ])
        ));  
    }

    handleFutureChanges(data) {
        const startDate = moment(data.startDate, 'X').startOf('day').add(1, 'days').unix();
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: (startDate < exclusionAvailabilityFromState.endDate) ? 
                parseInt(startDate, 10) : 
                exclusionAvailabilityFromState.endDate
        }) : data;

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [originAvailability, exclusionAvailability, data])
        ));          
    }

    renderAvailabilityAccordion() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }
        const onCopy = data => {
            this.onCopyAvailability(data)
        }

        const onExclusion = data => {
            this.onCreateExclusionForAvailability(data)
        }

        const onEditInFuture = data => {
            this.onEditAvailabilityInFuture(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const onUpdateSingle = data => {
            this.onUpdateSingleAvailability(data)
        }

        const onNew = data => {
            this.onNewAvailability(data)
        }

        const handleChange = (data) => {
            this.handleChange(data)
        }

        return <AccordionLayout 
            availabilityList={this.state.availabilitylist}
            data={this.state.selectedAvailability}
            today={this.state.today}
            timestamp={this.props.timestamp}
            title=""
            onSelect={onSelect}
            onPublish={this.onPublishAvailability.bind(this)}
            onUpdateSingle={onUpdateSingle}
            onDelete={onDelete}
            onNew={onNew}
            onAbort={this.onRevertUpdates.bind(this)}
            onCopy={onCopy}
            onExclusion={onExclusion}
            onEditInFuture={onEditInFuture}
            handleChange={handleChange}
            stateChanged={this.state.stateChanged}
            includeUrl={this.props.links.includeurl}
            setErrorRef={this.setErrorRef}
            errorList={this.state.errorList ? 
                this.state.errorList : {}
            }
            conflictList={this.state.conflictList ? 
                this.state.conflictList : 
                {itemList: {}, conflictIdList: {}}
            }
            isCreatingExclusion={this.isCreatingExclusion}
        />
    }

    renderSaveBar() {
        if (this.state.lastSave) {
            return (
                <SaveBar 
                    lastSave={this.state.lastSave} 
                    success={this.state.saveSuccess} 
                    setSuccessRef={this.setSuccessRef}
                    errorMessage={this.state.errorMessage}
                />
            )
        }
    }    

    render() {
        return (
            <PageLayout
                tabs={<TabsBar selected={this.state.selectedTab} tabs={this.props.tabs} onSelect={this.onTabSelect.bind(this)} />}
                timeTable={this.renderTimeTable()}
                saveBar={this.renderSaveBar()}
                accordion={this.renderAvailabilityAccordion()}
                conflicts={<Conflicts conflicts={this.state.conflicts} onSelect={this.onConflictedIdSelect.bind(this)} />}
            />
        )
    }
}

AvailabilityPage.propTypes = {
    maxworkstationcount: PropTypes.number,
    timestamp: PropTypes.number,
    scope: PropTypes.object,
    links: PropTypes.object,
    tabs: PropTypes.array
}

export default AvailabilityPage
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import $ from 'jquery'
import moment from 'moment'
import validate from './form/validate'
import Conflicts from './conflicts'
import TabsBar from './tabsbar'
import GraphView from './timetable/graphview.js'
import TableView from './timetable/tableview.js'
import SaveBar from './saveBar'
import AccordionLayout from './layouts/accordion'
import PageLayout from './layouts/page'
import { inArray, showSpinner, hideSpinner } from '../../lib/utils'
import ExceptionHandler from '../../lib/exceptionHandler';

import {
    getInitialState,
    getStateFromProps,
    writeSlotCalculationIntoAvailability,
    getNewAvailability,
    mergeAvailabilityListIntoState,
    updateAvailabilityInState,
    cleanupAvailabilityForSave,
    deleteAvailabilityInState,
    findAvailabilityInStateByKind,
    formatTimestampDate
} from "./helpers"

const tempId = (() => {
    let lastId = -1

    return () => {
        lastId += 1
        return `__temp__${lastId}`
    }
})()

class AvailabilityPage extends Component {
    constructor(props) {
        super(props)
        this.state = getInitialState(props);
        errorMessage: null;
        this.waitintervall = 1000;
        this.errorElement = null;
        this.successElement = null;
        this.isCreatingExclusion = false
        this.setErrorRef = element => {
            this.errorElement = element
        };
        this.setSuccessRef = element => {
            this.successElement = element
        };
    }

    handleScrollToBottom() {
        window.scrollTo(0, document.body.scrollHeight);
    }

    componentDidMount() {
        this.getValidationList()
        this.unloadHandler = ev => {
            const confirmMessage = "Es wurden nicht alle Änderungen gespeichert. Diese gehen beim schließen verloren."
            if (this.state.stateChanged) {
                ev.returnValue = confirmMessage
                return confirmMessage
            }
        }

        window.addEventListener('beforeunload', this.unloadHandler)
    }

    componentDidUnMount() {
        window.removeEventListener('beforeunload', this.unloadHandler)
    }

    onPublishAvailability() {
        this.getValidationList();
        this.getConflictList();
        let state = {};
        state = { selectedAvailability: null }
        this.setState(state, () => {
            this.onSaveUpdates();
        });
    }

    refreshData() {
        const currentDate = formatTimestampDate(this.props.timestamp)
        const url = `${this.props.links.includeurl}/scope/${this.props.scope.id}/availability/day/${currentDate}/conflicts/`
        $.ajax(url, {
            method: 'GET'
        }).done(data => {
            const newProps = {
                conflicts: data.conflicts,
                availabilitylist: data.availabilityList,
                busyslots: data.busySlotsForAvailabilities,
                maxslots: data.maxSlotsForAvailabilities,
                slotbuckets: data.slotBuckets,
            }
            this.setState(Object.assign({}, getStateFromProps(Object.assign({}, this.props, newProps)), {
                stateChanged: false
            }))

        }).fail(err => {
            console.log('refreshData error', err)
        })
    }

    onSaveUpdates() {
        const ok = confirm('Möchten Sie wirklich die Änderungen aller Öffnungszeiten speichern?');
        if (ok) {
            showSpinner();
    
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendData = this.state.availabilitylist
                .filter((availability) => {
                    return (
                        (availability.__modified || 
                        (availability.tempId && availability.tempId.includes('__temp__'))) &&
                        !this.hasErrors(availability)
                    );
                })
                .map(availability => {
                    const sendAvailability = Object.assign({}, availability);
                    if (availability.tempId) {
                        delete sendAvailability.tempId;
                    }
                    console.log(availability.kind);
                    return {
                        ...sendAvailability,
                        kind: availability.kind || 'default',
                    };
                })
                .map(cleanupAvailabilityForSave);
    
            const payload = {
                availabilityList: sendData,
                selectedDate: selectedDate
            };

            console.log('Saving updates', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((success) => {
                console.log('save success:', success);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail((err) => {
                let isException = err.responseText.toLowerCase().includes('exception');
                let errorMessage = '';
                
                // Format validation errors
                if (this.state.errorList && this.state.errorList.length > 0) {
                    errorMessage = 'Folgende Fehler sind bei der Prüfung Ihrer Eingaben aufgetreten:<br />';
                    errorMessage += this.state.errorList.map(error => error.message).join('<br />');
                }
                
                // Format conflict errors - this contains the properly formatted messages with dates/times
                if (this.state.conflictList && this.state.conflictList.itemList) {
                    const conflicts = Object.values(this.state.conflictList.itemList);
                    if (conflicts.length > 0) {
                        errorMessage += (errorMessage ? '<br /><br />' : '');
                        errorMessage += 'Folgende Zeiten führen mit der aktuellen Auswahl zu Konflikten:<br />';
                        errorMessage += conflicts.join('<br />');
                    }
                }
                
                // Only parse HTML error if no other errors exist
                if (err.responseText && !errorMessage) {
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(err.responseText, 'text/html');
                    const errorDiv = doc.querySelector('.message__body.message--error');
                    
                    if (errorDiv) {
                        const heading = errorDiv.querySelector('h3')?.textContent.trim();
                        // Don't show the generic error message
                        if (heading) errorMessage = heading;
                        
                        // Get conflicts after receiving error
                        this.getConflictList();
                    }
                }
            
                // Handle exception separately
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else if (err.status === 404) {
                    console.log('404 error, ignored');
                } else {
                    console.log('save all error', err);
                }
            
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                    errorMessage: errorMessage || 'Ein unbekannter Fehler ist aufgetreten'
                });
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }
    
    onRevertUpdates() {
        this.isCreatingExclusion = false
        this.setState(Object.assign({}, getInitialState(this.props), {
            selectedTab: this.state.selectedTab
        }), () => {
            this.refreshData()
            this.getValidationList()
        })
    }

    onUpdateSingleAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich aktualisiert werden?');
        const id = availability.id;
    
        if (ok) {
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendAvailability = Object.assign({}, availability);
    
            if (sendAvailability.tempId) {
                delete sendAvailability.tempId;
            }
    
            const payload = {
                availabilityList: [
                    {
                        ...cleanupAvailabilityForSave(sendAvailability),
                        kind: availability.kind || 'default'
                    }
                ],
                selectedDate: selectedDate
            };
    
            console.log('Updating single availability', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/save/${id}/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((data) => {
                console.log('Single update success:', data);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail(err => {
                const isException = err.responseText.toLowerCase().includes('exception');
                if (isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('Update error:', err);
                }
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                });
    
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }

    onDeleteAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich gelöscht werden?')
        const id = availability.id
        if (ok) {
            $.ajax(`${this.props.links.includeurl}/availability/delete/${id}/`, {
                method: 'GET'
            }).done(() => {
                this.setState(Object.assign({}, deleteAvailabilityInState(this.state, availability), {
                    selectedAvailability: null
                }), () => {
                    this.refreshData()
                    this.getConflictList(),
                    this.getValidationList()
                });
                hideSpinner();
            }).fail(err => {
                console.log('delete error', err);
                let isException = err.responseText.toLowerCase().includes('exception');
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('delete error', err);
                }
                hideSpinner();
            })
        } else {
            hideSpinner();
        }        
    }

    onCopyAvailability(availability) {
        const start = formatTimestampDate(availability.startDate)
        const end = formatTimestampDate(availability.endDate)

        let copysourcetitle = (availability.description) ? availability.description : `${start} - ${end}`;
        const copyAvailability = Object.assign({}, availability, {
            tempId: tempId(),
            id: null,
            description: `Kopie von ${copysourcetitle}`
        })
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [copyAvailability]),
            { selectedAvailability: copyAvailability, stateChanged: true }
        ), () => {
            this.getValidationList()
        })
    }

    onSelectAvailability(availability) {
        if (availability || ! this.state.selectedAvailability) {
            this.setState({
                selectedAvailability: availability
            }, () => {
                this.getValidationList()
            })
        } else {
            this.setState({
                selectedAvailability: null
            })
        }
        
    }

    editExclusionAvailability(availability, startDate, endDate, description, kind) {
        (startDate) ? availability.startDate = startDate : null;
        (endDate) ? availability.endDate = endDate : null;
        availability.__modified = true;
        if (! availability.kind && kind != 'origin') {
            availability.tempId = tempId()
            availability.id = null
            availability.description = (description) ? description : availability.description
            availability.kind = kind
        } else {
            availability.kind = 'origin'
        }
        return availability;
    }

    onCreateExclusionForAvailability(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        const tomorrow = selectedDay.clone().add(1, 'days')

        this.isCreatingExclusion = true;

        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        let name = availability.description;
        name = name.replaceAll('Ausnahme zu Terminserie ', '');
        name = name.replaceAll('Fortführung der Terminserie ', '');

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        if (availability.startDate === selectedDay.unix()) {
            originAvailability.description = `Ausnahme zu Terminserie ` + name;
        }

        let exclusionAvailability = originAvailability;
        if (originAvailability.startDate < selectedDay.unix()) {
            exclusionAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10), 
                parseInt(selectedDay.unix(), 10),
                `Ausnahme zu Terminserie ` + name,
                'exclusion'
            )
        }

        let futureAvailability = originAvailability;
        if (parseInt(tomorrow.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(tomorrow.unix(), 10),
                null,
                `Fortführung der Terminserie ` + name,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                exclusionAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: exclusionAvailability, 
                stateChanged: true 
            }
        ), () => {
            console.log('in after merging', this.state.availabilitylist);
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onEditAvailabilityInFuture(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        let futureAvailability = originAvailability;
        if (parseInt(selectedDay.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10),
                null,
                `Änderung ab ${formatTimestampDate(selectedDay)} (${availability.id})`,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: futureAvailability, 
                stateChanged: true 
            }
        ), () => {
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onNewAvailability() {
        let state = {};
        const newAvailability = getNewAvailability(this.props.timestamp, tempId(), this.props.scope);
        newAvailability.type = "appointment";
        
        state = Object.assign(
            state, 
            updateAvailabilityInState(this.state, newAvailability)
        );
        
        state.selectedAvailability = newAvailability;
        state.stateChanged = true;
        
        this.setState(state, () => {
            Promise.all([
                this.getValidationList(),
                this.getConflictList()
            ]).then(() => {
                this.setState({ selectedAvailability: null });
            });
            
            $('body').scrollTop(0);
        });
    }

    onTabSelect(tab) {
        this.setState({ selectedTab: tab.component });
    }

    onConflictedIdSelect(id) {
        const availability = this.state.availabilitylist.filter(availability => availability.id === id)[0]
        if (availability) {
            this.setState({ selectedAvailability: availability })
        }
    }

    hasErrors(availability) {
        let hasError = false;
        let hasConflict = false;

        if (this.state.errorList) {
            Object.values(this.state.errorList).forEach(errorItem => {
                if (availability.id === errorItem.id)
                    hasError = true;
            });
        }

        if (this.state.conflictList && this.state.conflictList.conflictIdList) {
            this.state.conflictList.conflictIdList.forEach(id => {
                if (availability.id === id)
                    hasConflict = true;
            });
        }

        return hasError || hasConflict;
    }

    getValidationList() {
        return new Promise((resolve, reject) => {
            const validateData = (data) => {
                const validationResult = validate(data, this.props);
                if (!validationResult.valid) {
                    return validationResult.errorList;
                }
                return [];
            };
    
            const list = this.state.availabilitylist
                .map(validateData)
                .flat();
    
            console.log("Validation list:", list);
    
            this.setState(
                {
                    errorList: list.length ? list : [],
                },
                () => {
                    if (list.length > 0) {
                        console.warn("Validation failed with errors:", list);
                        this.errorElement?.scrollIntoView();
                    } else {
                        console.log("Validation passed.");
                        resolve();
                    }
                }
            );
        });
    }
    
    validateAvailabilityList(availabilitylist) {
        const timeRegex = /^\d{2}:\d{2}(:\d{2})?$/;
    
        const isValidTimestamp = (timestamp) => !Number.isNaN(Number(timestamp)) && moment.unix(timestamp).isValid();
    
        const invalidAvailabilities = availabilitylist.filter((availability) => {
            const hasInvalidDates =
                !isValidTimestamp(availability.startDate) || !isValidTimestamp(availability.endDate);
            const hasInvalidTimes =
                !timeRegex.test(availability.startTime) || !timeRegex.test(availability.endTime);
    
            if (hasInvalidDates || hasInvalidTimes) {
                console.warn("Invalid availability detected:", availability);
            }
    
            return hasInvalidDates || hasInvalidTimes;
        });
    
        return invalidAvailabilities;
    }

    getConflictList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, selectedAvailability } = this.state;
                const { timestamp } = this.props;
                if (selectedAvailability.kind === 'exclusion') {
                    console.log("Skipping exclusion..")
                    return;
                }
                const nonExclusionAvailabilities = availabilitylist.filter(a => a.kind !== 'exclusion');

                selectedAvailability.startTime = moment(selectedAvailability.startTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');
                selectedAvailability.endTime = moment(selectedAvailability.endTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');

                const requestOptions = {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        availabilityList: nonExclusionAvailabilities,
                        selectedDate: formatTimestampDate(timestamp),
                        selectedAvailability,
                    }),
                };

                const url = `${this.props.links.includeurl}/availability/conflicts/`;

                fetch(url, requestOptions)
                    .then((res) => res.json())
                    .then(
                        (data) => {
                            console.log("Conflicts fetched successfully:", data);
                            this.setState({
                                conflictList: {
                                    itemList: { ...data.conflictList },
                                    conflictIdList: data.conflictIdList,
                                },
                            });
                            if (data.conflictIdList.length > 0) {
                                this.errorElement?.scrollIntoView();
                            }
                        },
                        (err) => {
                            console.error("Conflict fetch error:", err);
                            hideSpinner();
                        }
                    );
            })
            .catch((error) => {
                console.warn("Validation failed. Conflict fetch aborted.", error);
            });
    }
    
    renderTimeTable() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const ViewComponent = this.state.selectedTab == 'graph' ? GraphView : TableView;
        return <ViewComponent
            timestamp={this.props.timestamp}
            conflicts={this.state.conflicts}
            availabilityList={this.state.availabilitylistslices || this.state.availabilitylist}
            data={this.state.selectedAvailability}
            maxWorkstationCount={this.state.maxWorkstationCount || this.props.maxworkstationcount}
            links={this.props.links}
            onSelect={onSelect}
            onDelete={onDelete}
            onAbort={this.onRevertUpdates.bind(this)}
            slotBuckets={this.state.slotbuckets}
        />
    }

    readCalculatedAvailabilityList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, busyslots } = this.state;
    
                console.log("Validation passed. Proceeding with /availability/slots/.");
    
                $.ajax(`${this.props.links.includeurl}/availability/slots/`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    data: JSON.stringify({
                        availabilityList: availabilitylist,
                        busySlots: busyslots,
                    }),
                })
                    .done((responseData) => {
                        console.log("Slots fetched successfully:", responseData);
                        const availabilityList = writeSlotCalculationIntoAvailability(
                            this.state.availabilitylist,
                            responseData['maxSlots'],
                            responseData['busySlots']
                        );
                        this.setState({
                            availabilitylistslices: availabilityList,
                            maxWorkstationCount: parseInt(responseData['maxWorkstationCount']),
                        });
                    })
                    .fail((err) => {
                        console.error("Error during /availability/slots/ fetch:", err);
                        if (err.status === 404) {
                            console.log("404 error ignored.");
                            hideSpinner();
                        } else {
                            const isException = err.responseText.toLowerCase().includes("exception");
                            if (err.status >= 500 && isException) {
                                new ExceptionHandler($(".opened"), {
                                    code: err.status,
                                    message: err.responseText,
                                });
                            } else {
                                console.error("Unexpected error:", err.responseText);
                            }
                            hideSpinner();
                        }
                    });
            })
            .catch((error) => {
                console.warn("Validation failed. Slot calculation fetch aborted.", error);
                this.setState({ errorList: error });
                this.errorElement?.scrollIntoView();
            });
    }    
    
    handleChange(data) {
        if (data.__modified) {
            clearTimeout(this.timer)
            this.setState(
                Object.assign({}, updateAvailabilityInState(this.state, data)),
                () => {
                    this.readCalculatedAvailabilityList();
                    if (data.tempId || data.id) {
                        this.timer = setTimeout(() => {
                            this.getConflictList()
                            this.getValidationList()
                        }, this.waitintervall)
                    }
                }
            );
        }
        if (data.kind && inArray(data.kind, ["origin", "future", "exclusion"])) {
            this.handleChangesAvailabilityExclusion(data)
        }
    }

    handleChangesAvailabilityExclusion(data) {
        if ('origin' == data.kind && data.__modified) {
            this.handleOriginChanges(data)
        }
        if ('exclusion' == data.kind && data.__modified) {
            this.handleExclusionChanges(data)        
        }
        if ('future' == data.kind && data.__modified) {
            this.handleFutureChanges(data)
        }
    }

    handleOriginChanges(data) {
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: moment(data.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (exclusionAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                exclusionAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        }) : data;
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()
        });
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [exclusionAvailability, futureAvailability, data])
        ));          
    }

    handleExclusionChanges(data) {
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');

        const exclusionAvailability = Object.assign({}, data, {
            endDate: (data.startDate > data.endDate) ? data.startDate : data.endDate
        });

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        });

        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability, 
                futureAvailability, 
                exclusionAvailability
            ])
        ));  
    }

    handleFutureChanges(data) {
        const startDate = moment(data.startDate, 'X').startOf('day').add(1, 'days').unix();
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: (startDate < exclusionAvailabilityFromState.endDate) ? 
                parseInt(startDate, 10) : 
                exclusionAvailabilityFromState.endDate
        }) : data;

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [originAvailability, exclusionAvailability, data])
        ));          
    }

    renderAvailabilityAccordion() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }
        const onCopy = data => {
            this.onCopyAvailability(data)
        }

        const onExclusion = data => {
            this.onCreateExclusionForAvailability(data)
        }

        const onEditInFuture = data => {
            this.onEditAvailabilityInFuture(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const onUpdateSingle = data => {
            this.onUpdateSingleAvailability(data)
        }

        const onNew = data => {
            this.onNewAvailability(data)
        }

        const handleChange = (data) => {
            this.handleChange(data)
        }

        return <AccordionLayout 
            availabilityList={this.state.availabilitylist}
            data={this.state.selectedAvailability}
            today={this.state.today}
            timestamp={this.props.timestamp}
            title=""
            onSelect={onSelect}
            onPublish={this.onPublishAvailability.bind(this)}
            onUpdateSingle={onUpdateSingle}
            onDelete={onDelete}
            onNew={onNew}
            onAbort={this.onRevertUpdates.bind(this)}
            onCopy={onCopy}
            onExclusion={onExclusion}
            onEditInFuture={onEditInFuture}
            handleChange={handleChange}
            stateChanged={this.state.stateChanged}
            includeUrl={this.props.links.includeurl}
            setErrorRef={this.setErrorRef}
            errorList={this.state.errorList ? 
                this.state.errorList : {}
            }
            conflictList={this.state.conflictList ? 
                this.state.conflictList : 
                {itemList: {}, conflictIdList: {}}
            }
            isCreatingExclusion={this.isCreatingExclusion}
        />
    }

    renderSaveBar() {
        if (this.state.lastSave) {
            return (
                <SaveBar 
                    lastSave={this.state.lastSave} 
                    success={this.state.saveSuccess} 
                    setSuccessRef={this.setSuccessRef}
                    errorMessage={this.state.errorMessage}
                />
            )
        }
    }    

    render() {
        return (
            <PageLayout
                tabs={<TabsBar selected={this.state.selectedTab} tabs={this.props.tabs} onSelect={this.onTabSelect.bind(this)} />}
                timeTable={this.renderTimeTable()}
                saveBar={this.renderSaveBar()}
                accordion={this.renderAvailabilityAccordion()}
                conflicts={<Conflicts conflicts={this.state.conflicts} onSelect={this.onConflictedIdSelect.bind(this)} />}
            />
        )
    }
}

AvailabilityPage.propTypes = {
    maxworkstationcount: PropTypes.number,
    timestamp: PropTypes.number,
    scope: PropTypes.object,
    links: PropTypes.object,
    tabs: PropTypes.array
}

export default AvailabilityPage

@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for FormButtons.js:

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        isCreatingExclusion
    } = props;

    // For existing availabilities, only check conflicts/errors
    // For new availabilities, also check ID
    const updateDisabled = !data.id || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    // Keep other buttons' logic unchanged
    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

The key change is removing the !data.__modified check from updateDisabled. This means:

  1. For new availabilities (!data.id), the button stays disabled
  2. For existing availabilities, the button is enabled by default
  3. The button becomes disabled when there are conflicts or validation errors
  4. This matches the behavior of FooterButtons

(`・ω・´)ゞ thoughtfully considering button states

✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T17:25:12.124Z
Learning: In the eappointment system's FormButtons component, the update button's disabled state for existing availabilities should only depend on conflicts/errors, not on the __modified flag. The __modified flag should only be used for new availabilities.

@ThomasAFink
Copy link
Member Author

ThomasAFink commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for FormButtons.js:

const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        isCreatingExclusion
    } = props;

    // For existing availabilities, only check conflicts/errors
    // For new availabilities, also check ID
    const updateDisabled = !data.id || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    // Keep other buttons' logic unchanged
    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

The key change is removing the !data.__modified check from updateDisabled. This means:

  1. For new availabilities (!data.id), the button stays disabled
  2. For existing availabilities, the button is enabled by default
  3. The button becomes disabled when there are conflicts or validation errors
  4. This matches the behavior of FooterButtons

(`・ω・´)ゞ thoughtfully considering button states

✏️ Learnings added

nope doesn't work currently the button always remains enabled. @coderabbitai

<button title="Öffnungszeit aktualisieren" class="button button--diamond">Aktualisieren</button>

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix:

  1. Update AvailabilityForm's render method:
render() {
    const { data } = this.state
    const onChange = (name, value) => {
        this.handleChange(name, value)
    }

    const hasConflicts = this.props.conflictList.conflictIdList && 
        this.props.conflictList.conflictIdList.length > 0;
    
    const hasValidationErrors = this.props.errorList &&
        Object.keys(this.props.errorList).length > 0;

    return (
        <div>
            {<FormContent 
                today = {this.props.today} 
                availabilityList={this.props.availabilityList}
                setErrorRef={this.props.setErrorRef}
                errorList={this.props.errorList}
                conflictList={this.props.conflictList}
                {... { data, onChange }} />}
            {<FormButtons 
                data = {data}
                onCopy={this.props.onCopy} 
                onExclusion={this.props.onExclusion}
                onEditInFuture={this.props.onEditInFuture} 
                onDelete={this.props.onDelete}
                onUpdateSingle={this.props.onUpdateSingle}
                selectedDate={this.props.selectedDate}
                hasConflicts={hasConflicts}
                hasValidationErrors={hasValidationErrors}
                hasSlotCountError={hasSlotCountError(this.props)}
                isCreatingExclusion={this.props.isCreatingExclusion}
            />}
        </div>
    )   
}
  1. Update FormButtons component:
const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        isCreatingExclusion
    } = props;

    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);
    const updateDisabled = !data.id || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

This should now properly disable the update button when there are validation errors or conflicts.


✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T17:12:55.937Z
Learning: In the eappointment system's FormButtons component, the update button should be disabled when there are conflicts regardless of whether the form has been modified, to prevent updates that would create overlapping availabilities.
🔗 Analysis chain
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for where conflictList and errorList are being set/updated
rg -A 5 "conflictList|errorList" --type js

# Search for where hasConflicts and hasValidationErrors are being used
rg -A 5 "hasConflicts|hasValidationErrors" --type js

# Search for where FormButtons is rendered
rg -A 10 "FormButtons" --type js

Length of output: 48451


🧠 Learnings used
Learnt from: ThomasAFink
PR: it-at-m/eappointment#0
File: :0-0
Timestamp: 2025-01-27T17:21:52.671Z
Learning: In the eappointment system's FormButtons component, the update button's disabled state should not depend on data.__modified when checking for conflicts/errors, as this prevents re-enabling the button when conflicts are resolved. Only use __modified for new availabilities.

Learnt from: ThomasAFink
PR: it-at-m/eappointment#0
File: :0-0
Timestamp: 2025-01-27T17:12:55.937Z
Learning: In the eappointment system's FormButtons component, the update button should be disabled when there are conflicts regardless of whether the form has been modified, to prevent updates that would create overlapping availabilities.

Learnt from: ThomasAFink
PR: it-at-m/eappointment#0
File: :0-0
Timestamp: 2025-01-27T17:24:32.391Z
Learning: In the eappointment system's FormButtons component, the update button should require both data.__modified=true AND no conflicts/errors to be enabled. This ensures proper state transitions: disabled by default -> enabled on valid changes -> disabled on conflicts -> re-enabled when conflicts resolved.

@ThomasAFink
Copy link
Member Author

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix:

  1. Update AvailabilityForm's render method:
render() {
    const { data } = this.state
    const onChange = (name, value) => {
        this.handleChange(name, value)
    }

    const hasConflicts = this.props.conflictList.conflictIdList && 
        this.props.conflictList.conflictIdList.length > 0;
    
    const hasValidationErrors = this.props.errorList &&
        Object.keys(this.props.errorList).length > 0;

    return (
        <div>
            {<FormContent 
                today = {this.props.today} 
                availabilityList={this.props.availabilityList}
                setErrorRef={this.props.setErrorRef}
                errorList={this.props.errorList}
                conflictList={this.props.conflictList}
                {... { data, onChange }} />}
            {<FormButtons 
                data = {data}
                onCopy={this.props.onCopy} 
                onExclusion={this.props.onExclusion}
                onEditInFuture={this.props.onEditInFuture} 
                onDelete={this.props.onDelete}
                onUpdateSingle={this.props.onUpdateSingle}
                selectedDate={this.props.selectedDate}
                hasConflicts={hasConflicts}
                hasValidationErrors={hasValidationErrors}
                hasSlotCountError={hasSlotCountError(this.props)}
                isCreatingExclusion={this.props.isCreatingExclusion}
            />}
        </div>
    )   
}
  1. Update FormButtons component:
const FormButtons = (props) => {
    const { 
        data, 
        onCopy, 
        onExclusion, 
        onEditInFuture, 
        onUpdateSingle, 
        onDelete, 
        selectedDate, 
        hasConflicts,
        hasValidationErrors,
        hasSlotCountError,
        isCreatingExclusion
    } = props;

    const disabled = ((data && (!data.id || data.__modified === true)) || hasConflicts || hasSlotCountError);
    const updateDisabled = !data.id || hasConflicts || hasValidationErrors || hasSlotCountError || isCreatingExclusion;

    return (
        <div className="body">
            <div className="form-actions">
                <button onClick={onDelete}
                    title="Ausgewählte Öffnungszeit löschen"
                    className="button button--destructive button-delete" disabled={disabled}>Löschen</button>
                <button onClick={onCopy}
                    title="Öffnungszeit kopieren und bearbeiten"
                    className="button button--diamond" disabled={disabled}>Kopieren</button>
                <button onClick={onExclusion}
                    title="Ausnahme von dieser Öffnungszeit eintragen"
                    className="button button--diamond" disabled={disabled || data.endDate == selectedDate}>Ausnahme</button>
                <button onClick={onEditInFuture}
                    title="Öffnungszeit ab diesem Tag ändern"
                    className="button button--diamond" disabled={disabled || data.startDate == selectedDate}>Ab diesem Tag ändern</button> 
                <button onClick={onUpdateSingle}
                    title="Öffnungszeit aktualisieren"
                    className="button button--diamond" disabled={updateDisabled}>Aktualisieren</button>
            </div>
        </div>
    )
}

This should now properly disable the update button when there are validation errors or conflicts.

✏️ Learnings added
🔗 Analysis chain
🏁 Scripts executed
🧠 Learnings used

@coderabbitai it does not work. please give me something that actually works.

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fix for AvailabilityForm's render method:

render() {
    const { data } = this.state
    const onChange = (name, value) => {
        this.handleChange(name, value)
    }

    // Separate conflicts and validation errors
    const hasConflicts = this.props.conflictList.itemList && 
        Object.keys(this.props.conflictList.itemList).length > 0;
    
    const hasValidationErrors = this.props.errorList &&
        Object.keys(this.props.errorList).length > 0;

    return (
        <div>
            {<FormContent 
                today = {this.props.today} 
                availabilityList={this.props.availabilityList}
                setErrorRef={this.props.setErrorRef}
                errorList={this.props.errorList}
                conflictList={this.props.conflictList}
                {... { data, onChange }} />}
            {<FormButtons 
                data = {data}
                onCopy={this.props.onCopy} 
                onExclusion={this.props.onExclusion}
                onEditInFuture={this.props.onEditInFuture} 
                onDelete={this.props.onDelete}
                onUpdateSingle={this.props.onUpdateSingle}
                selectedDate={this.props.selectedDate}
                hasConflicts={hasConflicts}
                hasValidationErrors={hasValidationErrors}
                hasSlotCountError={hasSlotCountError(this.props)}
                isCreatingExclusion={this.props.isCreatingExclusion}
            />}
        </div>
    )   
}

This separates the conflict and validation error checks, ensuring the update button is properly disabled when either exists.


✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T17:35:12.124Z
Learning: In the eappointment system's AvailabilityForm component, keep conflict checks and validation error checks separate. Don't combine them into a single boolean, as they need to be handled independently for proper button state management.

🔗 Analysis chain
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for where conflictList and errorList are being set in state
rg "setState.*conflictList|setState.*errorList" --type js

# Search for where FormButtons is rendered with its props
rg -A 5 "FormButtons.*conflictList|FormButtons.*errorList" --type js

# Search for any state resets or cleanups
rg "getInitialState|cleanupFormData|resetState" --type js

Length of output: 65822


Script:

#!/bin/bash
# Get FormButtons component content
cat zmsadmin/js/page/availabilityDay/form/formButtons.js

# Get content of file where FormButtons is used
cat zmsadmin/js/page/availabilityDay/form/index.js

# Get content of file where conflicts are managed
cat zmsadmin/js/page/availabilityDay/index.js

Length of output: 38097

@ThomasAFink
Copy link
Member Author

ThomasAFink commented Jan 28, 2025

@coderabbitai the Uhrzeit timepicker currently grays out the times where opening hours already exist however the default values often overlap

    startTime: '07:00:00',
    endTime: '20:00:00',

if we leave the default values blank it takes the current time. how can we choose default values always one hour apart that never overlap conflict with exist hours? So say if in the morning there'S space set the default values in the morning starting from 05:00:00 if there's no space in the morning then take the last ending hour where there is space.

import moment from 'moment'

export const getStateFromProps = props => {
    return {
        availabilitylistslices: writeSlotCalculationIntoAvailability(
            props.availabilitylist,
            props.maxslots,
            props.busyslots
        ),
        availabilitylist: props.availabilitylist,
        conflicts: props.conflicts,
        today: props.today,
        busyslots: props.busyslots,
        slotbuckets: props.slotbuckets,
    }
}

export const writeSlotCalculationIntoAvailability = (availabilitylist, maxslots, busyslots) => {
    return availabilitylist.map(item => {
        let itemId = item.id ? item.id : item.tempId;
        return Object.assign({}, item, {
            maxSlots: maxslots[itemId] || 0,
            busySlots: busyslots[itemId] || 0
        })
    })
}

export const mergeAvailabilityListIntoState = (state, list) => list.reduce(updateAvailabilityInState, state)

/**
 * Compare two availabilityList if they are the same using ID
 */
const equalIds = (a, b) => {
    return (a.id && b.id && a.id === b.id) || (a.tempId && b.tempId && a.tempId === b.tempId)
}

export const findAvailabilityInStateByKind = (state, kind) => {
    return state.availabilitylist.find(availabilty => availabilty.kind == kind);
}

export const updateAvailabilityInState = (state, newAvailability) => {
    let updated = false

    const newState = Object.assign({}, state, {
        availabilitylist: state.availabilitylist.map(availability => {
            if (equalIds(availability, newAvailability)) {
                updated = true
                return newAvailability
            } else {
                return availability
            }
        })
    })

    if (!updated) {
        newState.availabilitylist.push(newAvailability)
    }

    newState.stateChanged = true

    return newState
}

export const deleteAvailabilityInState = (state, deleteAvailability) => {
    return Object.assign({}, state, {
        stateChanged: true,
        availabilitylist: state.availabilitylist.filter(availabilty => availabilty.id !== deleteAvailability.id)
    })
}

export const formatTimestampDate = timestamp => moment(timestamp, 'X').format('YYYY-MM-DD')

export const getInitialState = (props) => Object.assign({}, {
    availabilitylist: [],
    selectedAvailability: null,
    formTitle: null,
    lastSave: null,
    stateChanged: false,
    selectedTab: 'table',
}, getStateFromProps(props))

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    console.log(scope)

    const newAvailability = {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: '07:00:00',
        endTime: '20:00:00',
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }

    return newAvailability
}

export const availabilityTypes = [
    { value: "openinghours", name: "Spontankunden" },
    { value: "appointment", name: "Terminkunden" },
]

export const weekDayList = [
    { value: "monday", label: "Montag" },
    { value: "tuesday", label: "Dienstag" },
    { value: "wednesday", label: "Mittwoch" },
    { value: "thursday", label: "Donnerstag" },
    { value: "friday", label: "Freitag" },
    { value: "saturday", label: "Samstag" },
    { value: "sunday", label: "Sonntag" }
]

export const availabilitySeries = [
    { value: "0", name: "einmaliger Termin" },
    { value: "-1", name: "jede Woche" },
    { value: "-2", name: "alle 2 Wochen" },
    { value: "-3", name: "alle 3 Wochen" },
    { value: "1", name: "jede 1. Woche im Monat" },
    { value: "2", name: "jede 2. Woche im Monat" },
    { value: "3", name: "jede 3. Woche im Monat" },
    { value: "4", name: "jede 4. Woche im Monat" },
    { value: "5", name: "jede letzte Woche im Monat" }
]

export const repeat = repeat => {
    if (repeat.afterWeeks > 0) {
        return -repeat.afterWeeks
    } else if (repeat.weekOfMonth > 0) {
        return repeat.weekOfMonth
    } else {
        return 0
    }
}

export const filterEmptyAvailability = (availability) => {
    return availability.startDate != null && availability.endDate != null
}

export const cleanupAvailabilityForSave = availability => {
    const newAvailability = Object.assign({}, availability)

    if (newAvailability.busySlots) {
        delete newAvailability.busySlots;
    }

    if (newAvailability.maxSlots) {
        delete newAvailability.maxSlots;
    }

    if (newAvailability.__modified) {
        delete newAvailability.__modified;
    }

    if (newAvailability.tempId) {
        delete newAvailability.tempId;
    }

    return newAvailability;
}

export const getDataValuesFromForm = (form, scope) => {

    return Object.assign({}, getFirstLevelValues(form), {
        bookable: {
            startInDays: (!form.open_from || form.open_from === "" || form.open_from === "0") ? scope.preferences.appointment.startInDaysDefault : form.open_from,
            endInDays: (!form.open_to || form.open_to === "" || form.open_to === "0") ? scope.preferences.appointment.endInDaysDefault : form.open_to
        },
        workstationCount: {
            intern: form.workstationCount_intern,
            callcenter: form.workstationCount_callcenter,
            "public": form.workstationCount_public
        },
        weekday: form.weekday.reduce((carry, current) => {
            return Object.assign({}, carry, { [current]: 1 })
        }, {}),
        repeat: {
            weekOfMonth: form.repeat > 0 ? form.repeat : 0,
            afterWeeks: form.repeat < 0 ? -form.repeat : 0
        }
    })
}

export const cleanupFormData = data => {
    let internCount = parseInt(data.workstationCount_intern, 10);
    let callcenterCount = parseInt(data.workstationCount_callcenter, 10);
    callcenterCount = (callcenterCount > internCount) ? internCount : callcenterCount;
    let publicCount = parseInt(data.workstationCount_public, 10);
    publicCount = (publicCount > internCount) ? internCount : publicCount;
    return Object.assign({}, data, {
        workstationCount_callcenter: callcenterCount,
        workstationCount_public: publicCount,
        open_from: (!data.open_from || data.open_from === data.openFromDefault) ? "" : data.open_from,
        open_to: (!data.open_to || data.open_to === data.openToDefault) ? "" : data.open_to
    })
}

export const getFirstLevelValues = data => {
    const {
        __modified,
        scope,
        description,
        startTime,
        endTime,
        startDate,
        endDate,
        multipleSlotsAllowed,
        id,
        tempId,
        type,
        slotTimeInMinutes,
        kind
    } = data

    return {
        __modified,
        scope,
        description,
        startTime,
        endTime,
        startDate,
        endDate,
        multipleSlotsAllowed,
        id,
        tempId,
        type,
        slotTimeInMinutes,
        kind
    }
}

export const getFormValuesFromData = data => {
    const workstations = Object.assign({}, data.workstationCount)

    if (parseInt(workstations.callcenter, 10) > parseInt(workstations.intern, 10)) {
        workstations.callcenter = workstations.intern
    }

    if (parseInt(workstations.public, 10) > parseInt(workstations.intern, 10)) {
        workstations.public = workstations.intern
    }

    const openFrom = data.bookable.startInDays
    const openFromDefault = data.scope.preferences.appointment.startInDaysDefault
    const openTo = data.bookable.endInDays
    const openToDefault = data.scope.preferences.appointment.endInDaysDefault
    const repeatSeries = repeat(data.repeat);

    return cleanupFormData(Object.assign({}, getFirstLevelValues(data), {
        open_from: openFrom,
        open_to: openTo,
        openFromDefault,
        openToDefault,
        repeat: repeatSeries,
        workstationCount_intern: workstations.intern,
        workstationCount_callcenter: workstations.callcenter,
        workstationCount_public: workstations.public,
        weekday: Object.keys(data.weekday).filter(key => parseInt(data.weekday[key], 10) > 0)
    }))
}

export const accordionTitle = (data) => {
    const startDate = moment(data.startDate, 'X').format('DD.MM.YYYY');
    const endDate = moment(data.endDate, 'X').format('DD.MM.YYYY');
    const startTime = moment(data.startTime, 'h:mm:ss').format('HH:mm');
    const endTime = moment(data.endTime, 'h:mm:ss').format('HH:mm');
    const availabilityType = availabilityTypes.find(element => element.value == data.type);
    const availabilityWeekDayList = Object.keys(data.weekday).filter(key => parseInt(data.weekday[key], 10) > 0)
    const availabilityWeekDay = weekDayList.filter(element => availabilityWeekDayList.includes(element.value)
    ).map(item => item.label).join(', ')
    let description = (data.description) ? `: ${data.description}` : "";
    let type = (availabilityType && availabilityWeekDay) ? ` Typ: ${availabilityType.name}, Wochentag: ${availabilityWeekDay}` : "";
    return `Zeitraum: ${startDate} bis ${endDate}, Uhrzeit: von ${startTime} bis ${endTime}, ${type}${description}`

}
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import $ from 'jquery'
import moment from 'moment'
import validate from './form/validate'
import Conflicts from './conflicts'
import TabsBar from './tabsbar'
import GraphView from './timetable/graphview.js'
import TableView from './timetable/tableview.js'
import SaveBar from './saveBar'
import AccordionLayout from './layouts/accordion'
import PageLayout from './layouts/page'
import { inArray, showSpinner, hideSpinner } from '../../lib/utils'
import ExceptionHandler from '../../lib/exceptionHandler';

import {
    getInitialState,
    getStateFromProps,
    writeSlotCalculationIntoAvailability,
    getNewAvailability,
    mergeAvailabilityListIntoState,
    updateAvailabilityInState,
    cleanupAvailabilityForSave,
    deleteAvailabilityInState,
    findAvailabilityInStateByKind,
    formatTimestampDate
} from "./helpers"

const tempId = (() => {
    let lastId = -1

    return () => {
        lastId += 1
        return `__temp__${lastId}`
    }
})()

class AvailabilityPage extends Component {
    constructor(props) {
        super(props)
        this.state = getInitialState(props)
        this.waitintervall = 1000;
        this.errorElement = null;
        this.successElement = null;
        this.isCreatingExclusion = false
        this.setErrorRef = element => {
            this.errorElement = element
        };
        this.setSuccessRef = element => {
            this.successElement = element
        };
    }

    handleScrollToBottom() {
        window.scrollTo(0, document.body.scrollHeight);
    }

    componentDidMount() {
        this.getValidationList()
        this.unloadHandler = ev => {
            const confirmMessage = "Es wurden nicht alle Änderungen gespeichert. Diese gehen beim schließen verloren."
            if (this.state.stateChanged) {
                ev.returnValue = confirmMessage
                return confirmMessage
            }
        }

        window.addEventListener('beforeunload', this.unloadHandler)
    }

    componentDidUnMount() {
        window.removeEventListener('beforeunload', this.unloadHandler)
    }

    onPublishAvailability() {
        this.getValidationList();
        this.getConflictList();
        let state = {};
        state = { selectedAvailability: null }
        this.setState(state, () => {
            this.onSaveUpdates();
        });
    }

    refreshData() {
        const currentDate = formatTimestampDate(this.props.timestamp)
        const url = `${this.props.links.includeurl}/scope/${this.props.scope.id}/availability/day/${currentDate}/conflicts/`
        $.ajax(url, {
            method: 'GET'
        }).done(data => {
            const newProps = {
                conflicts: data.conflicts,
                availabilitylist: data.availabilityList,
                busyslots: data.busySlotsForAvailabilities,
                maxslots: data.maxSlotsForAvailabilities,
                slotbuckets: data.slotBuckets,
            }
            this.setState(Object.assign({}, getStateFromProps(Object.assign({}, this.props, newProps)), {
                stateChanged: false
            }))

        }).fail(err => {
            console.log('refreshData error', err)
        })
    }

    onSaveUpdates() {
        const ok = confirm('Möchten Sie wirklich die Änderungen aller Öffnungszeiten speichern?');
        if (ok) {
            showSpinner();
    
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendData = this.state.availabilitylist
                .filter((availability) => {
                    return (
                        (availability.__modified || 
                        (availability.tempId && availability.tempId.includes('__temp__'))) &&
                        !this.hasErrors(availability)
                    );
                })
                .map(availability => {
                    const sendAvailability = Object.assign({}, availability);
                    if (availability.tempId) {
                        delete sendAvailability.tempId;
                    }
                    console.log(availability.kind);
                    return {
                        ...sendAvailability,
                        kind: availability.kind || 'default',
                    };
                })
                .map(cleanupAvailabilityForSave);
    
            const payload = {
                availabilityList: sendData,
                selectedDate: selectedDate
            };

            console.log('Saving updates', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((success) => {
                console.log('save success:', success);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail((err) => {
                let isException = err.responseText.toLowerCase().includes('exception');
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else if (err.status === 404) {
                    console.log('404 error, ignored');
                } else {
                    console.log('save all error', err);
                }
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                });
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }
    
    onRevertUpdates() {
        this.isCreatingExclusion = false
        this.setState(Object.assign({}, getInitialState(this.props), {
            selectedTab: this.state.selectedTab
        }), () => {
            this.refreshData()
            this.getValidationList()
        })
    }

    onUpdateSingleAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich aktualisiert werden?');
        const id = availability.id;
    
        if (ok) {
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendAvailability = Object.assign({}, availability);
    
            if (sendAvailability.tempId) {
                delete sendAvailability.tempId;
            }
    
            const payload = {
                availabilityList: [
                    {
                        ...cleanupAvailabilityForSave(sendAvailability),
                        kind: availability.kind || 'default'
                    }
                ],
                selectedDate: selectedDate
            };
    
            console.log('Updating single availability', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/save/${id}/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((data) => {
                console.log('Single update success:', data);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail(err => {
                const isException = err.responseText.toLowerCase().includes('exception');
                if (isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('Update error:', err);
                }
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                });
    
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }

    onDeleteAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich gelöscht werden?')
        const id = availability.id
        if (ok) {
            $.ajax(`${this.props.links.includeurl}/availability/delete/${id}/`, {
                method: 'GET'
            }).done(() => {
                this.setState(Object.assign({}, deleteAvailabilityInState(this.state, availability), {
                    selectedAvailability: null
                }), () => {
                    this.refreshData()
                    this.getConflictList(),
                    this.getValidationList()
                });
                hideSpinner();
            }).fail(err => {
                console.log('delete error', err);
                let isException = err.responseText.toLowerCase().includes('exception');
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('delete error', err);
                }
                hideSpinner();
            })
        } else {
            hideSpinner();
        }        
    }

    onCopyAvailability(availability) {
        const start = formatTimestampDate(availability.startDate)
        const end = formatTimestampDate(availability.endDate)

        let copysourcetitle = (availability.description) ? availability.description : `${start} - ${end}`;
        const copyAvailability = Object.assign({}, availability, {
            tempId: tempId(),
            id: null,
            description: `Kopie von ${copysourcetitle}`
        })
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [copyAvailability]),
            { selectedAvailability: copyAvailability, stateChanged: true }
        ), () => {
            this.getValidationList()
        })
    }

    onSelectAvailability(availability) {
        if (availability || ! this.state.selectedAvailability) {
            this.setState({
                selectedAvailability: availability
            }, () => {
                this.getValidationList()
            })
        } else {
            this.setState({
                selectedAvailability: null
            })
        }
        
    }

    editExclusionAvailability(availability, startDate, endDate, description, kind) {
        (startDate) ? availability.startDate = startDate : null;
        (endDate) ? availability.endDate = endDate : null;
        availability.__modified = true;
        if (! availability.kind && kind != 'origin') {
            availability.tempId = tempId()
            availability.id = null
            availability.description = (description) ? description : availability.description
            availability.kind = kind
        } else {
            availability.kind = 'origin'
        }
        return availability;
    }

    onCreateExclusionForAvailability(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        const tomorrow = selectedDay.clone().add(1, 'days')

        this.isCreatingExclusion = true;

        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        let name = availability.description;
        name = name.replaceAll('Ausnahme zu Terminserie ', '');
        name = name.replaceAll('Fortführung der Terminserie ', '');

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        if (availability.startDate === selectedDay.unix()) {
            originAvailability.description = `Ausnahme zu Terminserie ` + name;
        }

        let exclusionAvailability = originAvailability;
        if (originAvailability.startDate < selectedDay.unix()) {
            exclusionAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10), 
                parseInt(selectedDay.unix(), 10),
                `Ausnahme zu Terminserie ` + name,
                'exclusion'
            )
        }

        let futureAvailability = originAvailability;
        if (parseInt(tomorrow.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(tomorrow.unix(), 10),
                null,
                `Fortführung der Terminserie ` + name,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                exclusionAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: exclusionAvailability, 
                stateChanged: true 
            }
        ), () => {
            console.log('in after merging', this.state.availabilitylist);
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onEditAvailabilityInFuture(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        let futureAvailability = originAvailability;
        if (parseInt(selectedDay.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10),
                null,
                `Änderung ab ${formatTimestampDate(selectedDay)} (${availability.id})`,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: futureAvailability, 
                stateChanged: true 
            }
        ), () => {
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onNewAvailability() {
        let state = {};
        const newAvailability = getNewAvailability(this.props.timestamp, tempId(), this.props.scope);
        newAvailability.type = "appointment";
        
        // First update state with new availability
        state = Object.assign(
            state, 
            updateAvailabilityInState(this.state, newAvailability)
        );
        
        // Set selected availability for conflict checking
        state.selectedAvailability = newAvailability;
        state.stateChanged = false; // Keep save button disabled by default
        
        this.setState(state, () => {
            // After state is updated, check for validation errors and conflicts
            Promise.all([
                this.getValidationList(),
                this.getConflictList()
            ]).then(() => {
                // Clear selected availability after checks
                this.setState({ selectedAvailability: null });
            });
            
            $('body').scrollTop(0);
        });
    }

    onTabSelect(tab) {
        this.setState({ selectedTab: tab.component });
    }

    onConflictedIdSelect(id) {
        const availability = this.state.availabilitylist.filter(availability => availability.id === id)[0]
        if (availability) {
            this.setState({ selectedAvailability: availability })
        }
    }

    hasErrors(availability) {
        let hasError = false;
        let hasConflict = false;

        if (this.state.errorList) {
            Object.values(this.state.errorList).forEach(errorItem => {
                if (availability.id === errorItem.id)
                    hasError = true;
            });
        }

        if (this.state.conflictList && this.state.conflictList.conflictIdList) {
            this.state.conflictList.conflictIdList.forEach(id => {
                if (availability.id === id)
                    hasConflict = true;
            });
        }

        return hasError || hasConflict;
    }

    getValidationList() {
        return new Promise((resolve, reject) => {
            const validateData = (data) => {
                const validationResult = validate(data, this.props);
                if (!validationResult.valid) {
                    return validationResult.errorList;
                }
                return [];
            };
    
            const list = this.state.availabilitylist
                .map(validateData)
                .flat();
    
            console.log("Validation list:", list);
    
            this.setState(
                {
                    errorList: list.length ? list : [],
                },
                () => {
                    if (list.length > 0) {
                        console.warn("Validation failed with errors:", list);
                        this.errorElement?.scrollIntoView();
                    } else {
                        console.log("Validation passed.");
                        resolve();
                    }
                }
            );
        });
    }
    
    validateAvailabilityList(availabilitylist) {
        const timeRegex = /^\d{2}:\d{2}(:\d{2})?$/;
    
        const isValidTimestamp = (timestamp) => !Number.isNaN(Number(timestamp)) && moment.unix(timestamp).isValid();
    
        const invalidAvailabilities = availabilitylist.filter((availability) => {
            const hasInvalidDates =
                !isValidTimestamp(availability.startDate) || !isValidTimestamp(availability.endDate);
            const hasInvalidTimes =
                !timeRegex.test(availability.startTime) || !timeRegex.test(availability.endTime);
    
            if (hasInvalidDates || hasInvalidTimes) {
                console.warn("Invalid availability detected:", availability);
            }
    
            return hasInvalidDates || hasInvalidTimes;
        });
    
        return invalidAvailabilities;
    }

    getConflictList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, selectedAvailability } = this.state;
                const { timestamp } = this.props;
                if (selectedAvailability.kind === 'exclusion') {
                    console.log("Skipping exclusion..")
                    return;
                }
                const nonExclusionAvailabilities = availabilitylist.filter(a => a.kind !== 'exclusion');

                selectedAvailability.startTime = moment(selectedAvailability.startTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');
                selectedAvailability.endTime = moment(selectedAvailability.endTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');

                const requestOptions = {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        availabilityList: nonExclusionAvailabilities,
                        selectedDate: formatTimestampDate(timestamp),
                        selectedAvailability,
                    }),
                };

                const url = `${this.props.links.includeurl}/availability/conflicts/`;

                fetch(url, requestOptions)
                    .then((res) => res.json())
                    .then(
                        (data) => {
                            console.log("Conflicts fetched successfully:", data);
                            this.setState({
                                conflictList: {
                                    itemList: { ...data.conflictList },
                                    conflictIdList: data.conflictIdList,
                                },
                            });
                            if (data.conflictIdList.length > 0) {
                                this.errorElement?.scrollIntoView();
                            }
                        },
                        (err) => {
                            console.error("Conflict fetch error:", err);
                            hideSpinner();
                        }
                    );
            })
            .catch((error) => {
                console.warn("Validation failed. Conflict fetch aborted.", error);
            });
    }
    
    renderTimeTable() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const ViewComponent = this.state.selectedTab == 'graph' ? GraphView : TableView;
        return <ViewComponent
            timestamp={this.props.timestamp}
            conflicts={this.state.conflicts}
            availabilityList={this.state.availabilitylistslices || this.state.availabilitylist}
            data={this.state.selectedAvailability}
            maxWorkstationCount={this.state.maxWorkstationCount || this.props.maxworkstationcount}
            links={this.props.links}
            onSelect={onSelect}
            onDelete={onDelete}
            onAbort={this.onRevertUpdates.bind(this)}
            slotBuckets={this.state.slotbuckets}
        />
    }

    readCalculatedAvailabilityList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, busyslots } = this.state;
    
                console.log("Validation passed. Proceeding with /availability/slots/.");
    
                $.ajax(`${this.props.links.includeurl}/availability/slots/`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    data: JSON.stringify({
                        availabilityList: availabilitylist,
                        busySlots: busyslots,
                    }),
                })
                    .done((responseData) => {
                        console.log("Slots fetched successfully:", responseData);
                        const availabilityList = writeSlotCalculationIntoAvailability(
                            this.state.availabilitylist,
                            responseData['maxSlots'],
                            responseData['busySlots']
                        );
                        this.setState({
                            availabilitylistslices: availabilityList,
                            maxWorkstationCount: parseInt(responseData['maxWorkstationCount']),
                        });
                    })
                    .fail((err) => {
                        console.error("Error during /availability/slots/ fetch:", err);
                        if (err.status === 404) {
                            console.log("404 error ignored.");
                            hideSpinner();
                        } else {
                            const isException = err.responseText.toLowerCase().includes("exception");
                            if (err.status >= 500 && isException) {
                                new ExceptionHandler($(".opened"), {
                                    code: err.status,
                                    message: err.responseText,
                                });
                            } else {
                                console.error("Unexpected error:", err.responseText);
                            }
                            hideSpinner();
                        }
                    });
            })
            .catch((error) => {
                console.warn("Validation failed. Slot calculation fetch aborted.", error);
                this.setState({ errorList: error });
                this.errorElement?.scrollIntoView();
            });
    }    
    
    handleChange(data) {
        if (data.__modified) {
            clearTimeout(this.timer)
            this.setState(
                Object.assign({}, updateAvailabilityInState(this.state, data)),
                () => {
                    this.readCalculatedAvailabilityList();
                    if (data.tempId || data.id) {
                        this.timer = setTimeout(() => {
                            this.getConflictList()
                            this.getValidationList()
                        }, this.waitintervall)
                    }
                }
            );
        }
        if (data.kind && inArray(data.kind, ["origin", "future", "exclusion"])) {
            this.handleChangesAvailabilityExclusion(data)
        }
    }

    handleChangesAvailabilityExclusion(data) {
        if ('origin' == data.kind && data.__modified) {
            this.handleOriginChanges(data)
        }
        if ('exclusion' == data.kind && data.__modified) {
            this.handleExclusionChanges(data)        
        }
        if ('future' == data.kind && data.__modified) {
            this.handleFutureChanges(data)
        }
    }

    handleOriginChanges(data) {
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: moment(data.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (exclusionAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                exclusionAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        }) : data;
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()
        });
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [exclusionAvailability, futureAvailability, data])
        ));          
    }

    handleExclusionChanges(data) {
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');

        const exclusionAvailability = Object.assign({}, data, {
            endDate: (data.startDate > data.endDate) ? data.startDate : data.endDate
        });

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        });

        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability, 
                futureAvailability, 
                exclusionAvailability
            ])
        ));  
    }

    handleFutureChanges(data) {
        const startDate = moment(data.startDate, 'X').startOf('day').add(1, 'days').unix();
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: (startDate < exclusionAvailabilityFromState.endDate) ? 
                parseInt(startDate, 10) : 
                exclusionAvailabilityFromState.endDate
        }) : data;

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [originAvailability, exclusionAvailability, data])
        ));          
    }

    renderAvailabilityAccordion() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }
        const onCopy = data => {
            this.onCopyAvailability(data)
        }

        const onExclusion = data => {
            this.onCreateExclusionForAvailability(data)
        }

        const onEditInFuture = data => {
            this.onEditAvailabilityInFuture(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const onUpdateSingle = data => {
            this.onUpdateSingleAvailability(data)
        }

        const onNew = data => {
            this.onNewAvailability(data)
        }

        const handleChange = (data) => {
            this.handleChange(data)
        }

        return <AccordionLayout 
            availabilityList={this.state.availabilitylist}
            data={this.state.selectedAvailability}
            today={this.state.today}
            timestamp={this.props.timestamp}
            title=""
            onSelect={onSelect}
            onPublish={this.onPublishAvailability.bind(this)}
            onUpdateSingle={onUpdateSingle}
            onDelete={onDelete}
            onNew={onNew}
            onAbort={this.onRevertUpdates.bind(this)}
            onCopy={onCopy}
            onExclusion={onExclusion}
            onEditInFuture={onEditInFuture}
            handleChange={handleChange}
            stateChanged={this.state.stateChanged}
            includeUrl={this.props.links.includeurl}
            setErrorRef={this.setErrorRef}
            errorList={this.state.errorList ? 
                this.state.errorList : {}
            }
            conflictList={this.state.conflictList ? 
                this.state.conflictList : 
                {itemList: {}, conflictIdList: {}}
            }
            isCreatingExclusion={this.isCreatingExclusion}
        />
    }

    renderSaveBar() {
        if (this.state.lastSave) {
            return (
                <SaveBar 
                    lastSave={this.state.lastSave} 
                    success={this.state.saveSuccess} 
                    setSuccessRef={this.setSuccessRef} 
                />
            )
        }
    }    

    render() {
        return (
            <PageLayout
                tabs={<TabsBar selected={this.state.selectedTab} tabs={this.props.tabs} onSelect={this.onTabSelect.bind(this)} />}
                timeTable={this.renderTimeTable()}
                saveBar={this.renderSaveBar()}
                accordion={this.renderAvailabilityAccordion()}
                conflicts={<Conflicts conflicts={this.state.conflicts} onSelect={this.onConflictedIdSelect.bind(this)} />}
            />
        )
    }
}

AvailabilityPage.propTypes = {
    maxworkstationcount: PropTypes.number,
    timestamp: PropTypes.number,
    scope: PropTypes.object,
    links: PropTypes.object,
    tabs: PropTypes.array
}

export default AvailabilityPage

@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
@it-at-m it-at-m deleted a comment from coderabbitai bot Jan 28, 2025
Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's how to modify getNewAvailability() to find non-overlapping time slots:

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Find first available 1-hour slot starting from 05:00
    let startTime = moment('05:00:00', 'HH:mm:ss')
    let endTime = moment(startTime).add(1, 'hour')
    let foundSlot = false

    // Check morning slots
    for(let i = 0; i < todayAvailabilities.length; i++) {
        const availability = todayAvailabilities[i]
        const availStart = moment(availability.startTime, 'HH:mm:ss')
        
        if(endTime.isBefore(availStart)) {
            foundSlot = true
            break
        }
        
        startTime = moment(availability.endTime, 'HH:mm:ss')
        endTime = moment(startTime).add(1, 'hour')
    }

    // If no morning slots, try after last availability
    if(!foundSlot && todayAvailabilities.length > 0) {
        const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
        startTime = moment(lastAvail.endTime, 'HH:mm:ss')
        endTime = moment(startTime).add(1, 'hour')
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

This will:

  1. Get all availabilities for the selected date
  2. Sort them by start time
  3. Try to find a 1-hour slot starting from 05:00
  4. If no morning slots available, place it after the last availability
  5. Ensure the new slot doesn't overlap with existing ones

✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T17:45:12.124Z
Learning: When creating new availabilities in the eappointment system, find non-overlapping time slots by checking existing availabilities for the day, starting from 05:00 and looking for 1-hour gaps. If no morning slots are available, place the new slot after the last availability.
🔗 Analysis chain
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Find where availabilitylist is populated/used
rg -A 5 "availabilitylist.*=" --type js

# Find any time-related constants or configurations
rg "startTime|endTime" --type js

Length of output: 69130

@ThomasAFink
Copy link
Member Author

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's how to modify getNewAvailability() to find non-overlapping time slots:

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Find first available 1-hour slot starting from 05:00
    let startTime = moment('05:00:00', 'HH:mm:ss')
    let endTime = moment(startTime).add(1, 'hour')
    let foundSlot = false

    // Check morning slots
    for(let i = 0; i < todayAvailabilities.length; i++) {
        const availability = todayAvailabilities[i]
        const availStart = moment(availability.startTime, 'HH:mm:ss')
        
        if(endTime.isBefore(availStart)) {
            foundSlot = true
            break
        }
        
        startTime = moment(availability.endTime, 'HH:mm:ss')
        endTime = moment(startTime).add(1, 'hour')
    }

    // If no morning slots, try after last availability
    if(!foundSlot && todayAvailabilities.length > 0) {
        const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
        startTime = moment(lastAvail.endTime, 'HH:mm:ss')
        endTime = moment(startTime).add(1, 'hour')
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

This will:

  1. Get all availabilities for the selected date
  2. Sort them by start time
  3. Try to find a 1-hour slot starting from 05:00
  4. If no morning slots available, place it after the last availability
  5. Ensure the new slot doesn't overlap with existing ones

✏️ Learnings added
🔗 Analysis chain
🏁 Scripts executed

@coderabbitai works but they also cannot be in the past:

Öffnungszeiten in der Vergangenheit lassen sich nicht bearbeiten (Die aktuelle Zeit "28.01.2025 11:29 Uhr" liegt nach dem Terminende am "28.01.2025 06:00 Uhr").

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the updated getNewAvailability() function that ensures slots are not in the past:

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Start from either 05:00 or current time, whichever is later
    const currentTime = moment()
    let startTime = moment('05:00:00', 'HH:mm:ss')
    if (currentTime.isAfter(startTime)) {
        // Round up to next half hour
        startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
    }
    let endTime = moment(startTime).add(1, 'hour')
    let foundSlot = false

    // Check slots
    for(let i = 0; i < todayAvailabilities.length; i++) {
        const availability = todayAvailabilities[i]
        const availStart = moment(availability.startTime, 'HH:mm:ss')
        
        if(endTime.isBefore(availStart)) {
            foundSlot = true
            break
        }
        
        startTime = moment(availability.endTime, 'HH:mm:ss')
        // Ensure we don't create slots in the past
        if (startTime.isBefore(currentTime)) {
            startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
        }
        endTime = moment(startTime).add(1, 'hour')
    }

    // If no slots found, try after last availability
    if(!foundSlot && todayAvailabilities.length > 0) {
        const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
        startTime = moment(lastAvail.endTime, 'HH:mm:ss')
        // Ensure we don't create slots in the past
        if (startTime.isBefore(currentTime)) {
            startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
        }
        endTime = moment(startTime).add(1, 'hour')
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

Key changes:

  1. Get current time and use it as minimum start time
  2. Round up to next half hour for cleaner slots
  3. Check each potential slot against current time
  4. Skip slots that would be in the past
  5. When finding next slot after an availability, ensure it's not in the past

✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T17:55:12.124Z
Learning: When creating new availabilities in the eappointment system, ensure start times are not in the past by comparing against current time and rounding up to the next half hour. This applies to both initial slots and slots after existing availabilities.

@ThomasAFink
Copy link
Member Author

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the updated getNewAvailability() function that ensures slots are not in the past:

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Start from either 05:00 or current time, whichever is later
    const currentTime = moment()
    let startTime = moment('05:00:00', 'HH:mm:ss')
    if (currentTime.isAfter(startTime)) {
        // Round up to next half hour
        startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
    }
    let endTime = moment(startTime).add(1, 'hour')
    let foundSlot = false

    // Check slots
    for(let i = 0; i < todayAvailabilities.length; i++) {
        const availability = todayAvailabilities[i]
        const availStart = moment(availability.startTime, 'HH:mm:ss')
        
        if(endTime.isBefore(availStart)) {
            foundSlot = true
            break
        }
        
        startTime = moment(availability.endTime, 'HH:mm:ss')
        // Ensure we don't create slots in the past
        if (startTime.isBefore(currentTime)) {
            startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
        }
        endTime = moment(startTime).add(1, 'hour')
    }

    // If no slots found, try after last availability
    if(!foundSlot && todayAvailabilities.length > 0) {
        const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
        startTime = moment(lastAvail.endTime, 'HH:mm:ss')
        // Ensure we don't create slots in the past
        if (startTime.isBefore(currentTime)) {
            startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
        }
        endTime = moment(startTime).add(1, 'hour')
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

Key changes:

  1. Get current time and use it as minimum start time
  2. Round up to next half hour for cleaner slots
  3. Check each potential slot against current time
  4. Skip slots that would be in the past
  5. When finding next slot after an availability, ensure it's not in the past

✏️ Learnings added

@coderabbitai okay doesn't quite works yet. why does it chose 12:00 - 13:00:

Folgende Zeiten führen mit der aktuellen Auswahl zu Konflikten:
Konflikt: Eine neue Öffnungszeit überschneidet sich mit einer bestehenden Öffnungszeit.
Bestehende Öffnungszeit:  [28.01.2025 - 28.01.2025, 07:00 - 20:00]
Neue Öffnungszeit:                 [28.01.2025 - 28.01.2025, 12:00 - 13:00]

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fixed version that properly checks for overlaps:

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Start from either 05:00 or current time, whichever is later
    const currentTime = moment()
    let startTime = moment('05:00:00', 'HH:mm:ss')
    if (currentTime.isAfter(startTime)) {
        // Round up to next half hour
        startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
    }
    let endTime = moment(startTime).add(1, 'hour')

    // Check if a time slot overlaps with any existing availability
    const hasOverlap = (start, end) => {
        return todayAvailabilities.some(availability => {
            const availStart = moment(availability.startTime, 'HH:mm:ss')
            const availEnd = moment(availability.endTime, 'HH:mm:ss')
            return !(end.isSameOrBefore(availStart) || start.isSameOrAfter(availEnd))
        })
    }

    // Try to find a slot before first availability
    if (todayAvailabilities.length > 0) {
        const firstAvail = todayAvailabilities[0]
        const firstStart = moment(firstAvail.startTime, 'HH:mm:ss')
        
        // If we can fit a slot before the first availability
        if (!hasOverlap(startTime, endTime) && endTime.isSameOrBefore(firstStart)) {
            // Use this slot
        } else {
            // Try after the last availability
            const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
            startTime = moment(lastAvail.endTime, 'HH:mm:ss')
            // Ensure we don't create slots in the past
            if (startTime.isBefore(currentTime)) {
                startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
            }
            endTime = moment(startTime).add(1, 'hour')
        }
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

Key changes:

  1. Added hasOverlap helper function to properly check for overlaps
  2. Simplified logic to try before first availability or after last availability
  3. Removed the loop that was incorrectly checking overlaps
  4. Maintained all the time rounding and past time checks

This should now correctly place the new slot either before 07:00 or after 20:00, depending on the current time.


✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T18:05:12.124Z
Learning: When finding non-overlapping time slots in the eappointment system, check both start and end times against existing availabilities using isSameOrBefore/isSameOrAfter to ensure complete non-overlap. Try slots before the first availability or after the last one.

@ThomasAFink
Copy link
Member Author

ThomasAFink commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fixed version that properly checks for overlaps:

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Start from either 05:00 or current time, whichever is later
    const currentTime = moment()
    let startTime = moment('05:00:00', 'HH:mm:ss')
    if (currentTime.isAfter(startTime)) {
        // Round up to next half hour
        startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
    }
    let endTime = moment(startTime).add(1, 'hour')

    // Check if a time slot overlaps with any existing availability
    const hasOverlap = (start, end) => {
        return todayAvailabilities.some(availability => {
            const availStart = moment(availability.startTime, 'HH:mm:ss')
            const availEnd = moment(availability.endTime, 'HH:mm:ss')
            return !(end.isSameOrBefore(availStart) || start.isSameOrAfter(availEnd))
        })
    }

    // Try to find a slot before first availability
    if (todayAvailabilities.length > 0) {
        const firstAvail = todayAvailabilities[0]
        const firstStart = moment(firstAvail.startTime, 'HH:mm:ss')
        
        // If we can fit a slot before the first availability
        if (!hasOverlap(startTime, endTime) && endTime.isSameOrBefore(firstStart)) {
            // Use this slot
        } else {
            // Try after the last availability
            const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
            startTime = moment(lastAvail.endTime, 'HH:mm:ss')
            // Ensure we don't create slots in the past
            if (startTime.isBefore(currentTime)) {
                startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
            }
            endTime = moment(startTime).add(1, 'hour')
        }
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

Key changes:

  1. Added hasOverlap helper function to properly check for overlaps
  2. Simplified logic to try before first availability or after last availability
  3. Removed the loop that was incorrectly checking overlaps
  4. Maintained all the time rounding and past time checks

This should now correctly place the new slot either before 07:00 or after 20:00, depending on the current time.

✏️ Learnings added

@coderabbitai it still keeps selecting: 12:00 - 13:00 even though there are other opening hours already there.

import moment from 'moment'

export const getStateFromProps = props => {
    return {
        availabilitylistslices: writeSlotCalculationIntoAvailability(
            props.availabilitylist,
            props.maxslots,
            props.busyslots
        ),
        availabilitylist: props.availabilitylist,
        conflicts: props.conflicts,
        today: props.today,
        busyslots: props.busyslots,
        slotbuckets: props.slotbuckets,
    }
}

export const writeSlotCalculationIntoAvailability = (availabilitylist, maxslots, busyslots) => {
    return availabilitylist.map(item => {
        let itemId = item.id ? item.id : item.tempId;
        return Object.assign({}, item, {
            maxSlots: maxslots[itemId] || 0,
            busySlots: busyslots[itemId] || 0
        })
    })
}

export const mergeAvailabilityListIntoState = (state, list) => list.reduce(updateAvailabilityInState, state)

/**
 * Compare two availabilityList if they are the same using ID
 */
const equalIds = (a, b) => {
    return (a.id && b.id && a.id === b.id) || (a.tempId && b.tempId && a.tempId === b.tempId)
}

export const findAvailabilityInStateByKind = (state, kind) => {
    return state.availabilitylist.find(availabilty => availabilty.kind == kind);
}

export const updateAvailabilityInState = (state, newAvailability) => {
    let updated = false

    const newState = Object.assign({}, state, {
        availabilitylist: state.availabilitylist.map(availability => {
            if (equalIds(availability, newAvailability)) {
                updated = true
                return newAvailability
            } else {
                return availability
            }
        })
    })

    if (!updated) {
        newState.availabilitylist.push(newAvailability)
    }

    newState.stateChanged = true

    return newState
}

export const deleteAvailabilityInState = (state, deleteAvailability) => {
    return Object.assign({}, state, {
        stateChanged: true,
        availabilitylist: state.availabilitylist.filter(availabilty => availabilty.id !== deleteAvailability.id)
    })
}

export const formatTimestampDate = timestamp => moment(timestamp, 'X').format('YYYY-MM-DD')

export const getInitialState = (props) => Object.assign({}, {
    availabilitylist: [],
    selectedAvailability: null,
    formTitle: null,
    lastSave: null,
    stateChanged: false,
    selectedTab: 'table',
}, getStateFromProps(props))

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Start from either 05:00 or current time, whichever is later
    const currentTime = moment()
    let startTime = moment('05:00:00', 'HH:mm:ss')
    if (currentTime.isAfter(startTime)) {
        // Round up to next half hour
        startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
    }
    let endTime = moment(startTime).add(1, 'hour')

    // Check if a time slot overlaps with any existing availability
    const hasOverlap = (start, end) => {
        return todayAvailabilities.some(availability => {
            const availStart = moment(availability.startTime, 'HH:mm:ss')
            const availEnd = moment(availability.endTime, 'HH:mm:ss')
            return !(end.isSameOrBefore(availStart) || start.isSameOrAfter(availEnd))
        })
    }

    // Try to find a slot before first availability
    if (todayAvailabilities.length > 0) {
        const firstAvail = todayAvailabilities[0]
        const firstStart = moment(firstAvail.startTime, 'HH:mm:ss')
        
        // If we can fit a slot before the first availability
        if (!hasOverlap(startTime, endTime) && endTime.isSameOrBefore(firstStart)) {
            // Use this slot
        } else {
            // Try after the last availability
            const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
            startTime = moment(lastAvail.endTime, 'HH:mm:ss')
            // Ensure we don't create slots in the past
            if (startTime.isBefore(currentTime)) {
                startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
            }
            endTime = moment(startTime).add(1, 'hour')
        }
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

export const availabilityTypes = [
    { value: "openinghours", name: "Spontankunden" },
    { value: "appointment", name: "Terminkunden" },
]

export const weekDayList = [
    { value: "monday", label: "Montag" },
    { value: "tuesday", label: "Dienstag" },
    { value: "wednesday", label: "Mittwoch" },
    { value: "thursday", label: "Donnerstag" },
    { value: "friday", label: "Freitag" },
    { value: "saturday", label: "Samstag" },
    { value: "sunday", label: "Sonntag" }
]

export const availabilitySeries = [
    { value: "0", name: "einmaliger Termin" },
    { value: "-1", name: "jede Woche" },
    { value: "-2", name: "alle 2 Wochen" },
    { value: "-3", name: "alle 3 Wochen" },
    { value: "1", name: "jede 1. Woche im Monat" },
    { value: "2", name: "jede 2. Woche im Monat" },
    { value: "3", name: "jede 3. Woche im Monat" },
    { value: "4", name: "jede 4. Woche im Monat" },
    { value: "5", name: "jede letzte Woche im Monat" }
]

export const repeat = repeat => {
    if (repeat.afterWeeks > 0) {
        return -repeat.afterWeeks
    } else if (repeat.weekOfMonth > 0) {
        return repeat.weekOfMonth
    } else {
        return 0
    }
}

export const filterEmptyAvailability = (availability) => {
    return availability.startDate != null && availability.endDate != null
}

export const cleanupAvailabilityForSave = availability => {
    const newAvailability = Object.assign({}, availability)

    if (newAvailability.busySlots) {
        delete newAvailability.busySlots;
    }

    if (newAvailability.maxSlots) {
        delete newAvailability.maxSlots;
    }

    if (newAvailability.__modified) {
        delete newAvailability.__modified;
    }

    if (newAvailability.tempId) {
        delete newAvailability.tempId;
    }

    return newAvailability;
}

export const getDataValuesFromForm = (form, scope) => {

    return Object.assign({}, getFirstLevelValues(form), {
        bookable: {
            startInDays: (!form.open_from || form.open_from === "" || form.open_from === "0") ? scope.preferences.appointment.startInDaysDefault : form.open_from,
            endInDays: (!form.open_to || form.open_to === "" || form.open_to === "0") ? scope.preferences.appointment.endInDaysDefault : form.open_to
        },
        workstationCount: {
            intern: form.workstationCount_intern,
            callcenter: form.workstationCount_callcenter,
            "public": form.workstationCount_public
        },
        weekday: form.weekday.reduce((carry, current) => {
            return Object.assign({}, carry, { [current]: 1 })
        }, {}),
        repeat: {
            weekOfMonth: form.repeat > 0 ? form.repeat : 0,
            afterWeeks: form.repeat < 0 ? -form.repeat : 0
        }
    })
}

export const cleanupFormData = data => {
    let internCount = parseInt(data.workstationCount_intern, 10);
    let callcenterCount = parseInt(data.workstationCount_callcenter, 10);
    callcenterCount = (callcenterCount > internCount) ? internCount : callcenterCount;
    let publicCount = parseInt(data.workstationCount_public, 10);
    publicCount = (publicCount > internCount) ? internCount : publicCount;
    return Object.assign({}, data, {
        workstationCount_callcenter: callcenterCount,
        workstationCount_public: publicCount,
        open_from: (!data.open_from || data.open_from === data.openFromDefault) ? "" : data.open_from,
        open_to: (!data.open_to || data.open_to === data.openToDefault) ? "" : data.open_to
    })
}

export const getFirstLevelValues = data => {
    const {
        __modified,
        scope,
        description,
        startTime,
        endTime,
        startDate,
        endDate,
        multipleSlotsAllowed,
        id,
        tempId,
        type,
        slotTimeInMinutes,
        kind
    } = data

    return {
        __modified,
        scope,
        description,
        startTime,
        endTime,
        startDate,
        endDate,
        multipleSlotsAllowed,
        id,
        tempId,
        type,
        slotTimeInMinutes,
        kind
    }
}

export const getFormValuesFromData = data => {
    const workstations = Object.assign({}, data.workstationCount)

    if (parseInt(workstations.callcenter, 10) > parseInt(workstations.intern, 10)) {
        workstations.callcenter = workstations.intern
    }

    if (parseInt(workstations.public, 10) > parseInt(workstations.intern, 10)) {
        workstations.public = workstations.intern
    }

    const openFrom = data.bookable.startInDays
    const openFromDefault = data.scope.preferences.appointment.startInDaysDefault
    const openTo = data.bookable.endInDays
    const openToDefault = data.scope.preferences.appointment.endInDaysDefault
    const repeatSeries = repeat(data.repeat);

    return cleanupFormData(Object.assign({}, getFirstLevelValues(data), {
        open_from: openFrom,
        open_to: openTo,
        openFromDefault,
        openToDefault,
        repeat: repeatSeries,
        workstationCount_intern: workstations.intern,
        workstationCount_callcenter: workstations.callcenter,
        workstationCount_public: workstations.public,
        weekday: Object.keys(data.weekday).filter(key => parseInt(data.weekday[key], 10) > 0)
    }))
}

export const accordionTitle = (data) => {
    const startDate = moment(data.startDate, 'X').format('DD.MM.YYYY');
    const endDate = moment(data.endDate, 'X').format('DD.MM.YYYY');
    const startTime = moment(data.startTime, 'h:mm:ss').format('HH:mm');
    const endTime = moment(data.endTime, 'h:mm:ss').format('HH:mm');
    const availabilityType = availabilityTypes.find(element => element.value == data.type);
    const availabilityWeekDayList = Object.keys(data.weekday).filter(key => parseInt(data.weekday[key], 10) > 0)
    const availabilityWeekDay = weekDayList.filter(element => availabilityWeekDayList.includes(element.value)
    ).map(item => item.label).join(', ')
    let description = (data.description) ? `: ${data.description}` : "";
    let type = (availabilityType && availabilityWeekDay) ? ` Typ: ${availabilityType.name}, Wochentag: ${availabilityWeekDay}` : "";
    return `Zeitraum: ${startDate} bis ${endDate}, Uhrzeit: von ${startTime} bis ${endTime}, ${type}${description}`

}
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import $ from 'jquery'
import moment from 'moment'
import validate from './form/validate'
import Conflicts from './conflicts'
import TabsBar from './tabsbar'
import GraphView from './timetable/graphview.js'
import TableView from './timetable/tableview.js'
import SaveBar from './saveBar'
import AccordionLayout from './layouts/accordion'
import PageLayout from './layouts/page'
import { inArray, showSpinner, hideSpinner } from '../../lib/utils'
import ExceptionHandler from '../../lib/exceptionHandler';

import {
    getInitialState,
    getStateFromProps,
    writeSlotCalculationIntoAvailability,
    getNewAvailability,
    mergeAvailabilityListIntoState,
    updateAvailabilityInState,
    cleanupAvailabilityForSave,
    deleteAvailabilityInState,
    findAvailabilityInStateByKind,
    formatTimestampDate
} from "./helpers"

const tempId = (() => {
    let lastId = -1

    return () => {
        lastId += 1
        return `__temp__${lastId}`
    }
})()

class AvailabilityPage extends Component {
    constructor(props) {
        super(props)
        this.state = getInitialState(props)
        this.waitintervall = 1000;
        this.errorElement = null;
        this.successElement = null;
        this.isCreatingExclusion = false
        this.setErrorRef = element => {
            this.errorElement = element
        };
        this.setSuccessRef = element => {
            this.successElement = element
        };
    }

    handleScrollToBottom() {
        window.scrollTo(0, document.body.scrollHeight);
    }

    componentDidMount() {
        this.getValidationList()
        this.unloadHandler = ev => {
            const confirmMessage = "Es wurden nicht alle Änderungen gespeichert. Diese gehen beim schließen verloren."
            if (this.state.stateChanged) {
                ev.returnValue = confirmMessage
                return confirmMessage
            }
        }

        window.addEventListener('beforeunload', this.unloadHandler)
    }

    componentDidUnMount() {
        window.removeEventListener('beforeunload', this.unloadHandler)
    }

    onPublishAvailability() {
        this.getValidationList();
        this.getConflictList();
        let state = {};
        state = { selectedAvailability: null }
        this.setState(state, () => {
            this.onSaveUpdates();
        });
    }

    refreshData() {
        const currentDate = formatTimestampDate(this.props.timestamp)
        const url = `${this.props.links.includeurl}/scope/${this.props.scope.id}/availability/day/${currentDate}/conflicts/`
        $.ajax(url, {
            method: 'GET'
        }).done(data => {
            const newProps = {
                conflicts: data.conflicts,
                availabilitylist: data.availabilityList,
                busyslots: data.busySlotsForAvailabilities,
                maxslots: data.maxSlotsForAvailabilities,
                slotbuckets: data.slotBuckets,
            }
            this.setState(Object.assign({}, getStateFromProps(Object.assign({}, this.props, newProps)), {
                stateChanged: false
            }))

        }).fail(err => {
            console.log('refreshData error', err)
        })
    }

    onSaveUpdates() {
        const ok = confirm('Möchten Sie wirklich die Änderungen aller Öffnungszeiten speichern?');
        if (ok) {
            showSpinner();
    
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendData = this.state.availabilitylist
                .filter((availability) => {
                    return (
                        (availability.__modified || 
                        (availability.tempId && availability.tempId.includes('__temp__'))) &&
                        !this.hasErrors(availability)
                    );
                })
                .map(availability => {
                    const sendAvailability = Object.assign({}, availability);
                    if (availability.tempId) {
                        delete sendAvailability.tempId;
                    }
                    console.log(availability.kind);
                    return {
                        ...sendAvailability,
                        kind: availability.kind || 'default',
                    };
                })
                .map(cleanupAvailabilityForSave);
    
            const payload = {
                availabilityList: sendData,
                selectedDate: selectedDate
            };

            console.log('Saving updates', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((success) => {
                console.log('save success:', success);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail((err) => {
                let isException = err.responseText.toLowerCase().includes('exception');
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else if (err.status === 404) {
                    console.log('404 error, ignored');
                } else {
                    console.log('save all error', err);
                }
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                });
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }
    
    onRevertUpdates() {
        this.isCreatingExclusion = false
        this.setState(Object.assign({}, getInitialState(this.props), {
            selectedTab: this.state.selectedTab
        }), () => {
            this.refreshData()
            this.getValidationList()
        })
    }

    onUpdateSingleAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich aktualisiert werden?');
        const id = availability.id;
    
        if (ok) {
            const selectedDate = formatTimestampDate(this.props.timestamp);
    
            const sendAvailability = Object.assign({}, availability);
    
            if (sendAvailability.tempId) {
                delete sendAvailability.tempId;
            }
    
            const payload = {
                availabilityList: [
                    {
                        ...cleanupAvailabilityForSave(sendAvailability),
                        kind: availability.kind || 'default'
                    }
                ],
                selectedDate: selectedDate
            };
    
            console.log('Updating single availability', payload);
    
            $.ajax(`${this.props.links.includeurl}/availability/save/${id}/`, {
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json'
            }).done((data) => {
                console.log('Single update success:', data);
                this.refreshData();
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: true,
                }, () => {
                    this.successElement.scrollIntoView();
                });
                hideSpinner();
                this.handleScrollToBottom();
            }).fail(err => {
                const isException = err.responseText.toLowerCase().includes('exception');
                if (isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('Update error:', err);
                }
                this.setState({
                    lastSave: new Date().getTime(),
                    saveSuccess: false,
                });
    
                this.getValidationList();
                hideSpinner();
                this.handleScrollToBottom();
            });
        } else {
            hideSpinner();
        }
    }

    onDeleteAvailability(availability) {
        showSpinner();
        const ok = confirm('Soll diese Öffnungszeit wirklich gelöscht werden?')
        const id = availability.id
        if (ok) {
            $.ajax(`${this.props.links.includeurl}/availability/delete/${id}/`, {
                method: 'GET'
            }).done(() => {
                this.setState(Object.assign({}, deleteAvailabilityInState(this.state, availability), {
                    selectedAvailability: null
                }), () => {
                    this.refreshData()
                    this.getConflictList(),
                    this.getValidationList()
                });
                hideSpinner();
            }).fail(err => {
                console.log('delete error', err);
                let isException = err.responseText.toLowerCase().includes('exception');
                if (err.status >= 500 && isException) {
                    new ExceptionHandler($('.opened'), {
                        code: err.status,
                        message: err.responseText
                    });
                } else {
                    console.log('delete error', err);
                }
                hideSpinner();
            })
        } else {
            hideSpinner();
        }        
    }

    onCopyAvailability(availability) {
        const start = formatTimestampDate(availability.startDate)
        const end = formatTimestampDate(availability.endDate)

        let copysourcetitle = (availability.description) ? availability.description : `${start} - ${end}`;
        const copyAvailability = Object.assign({}, availability, {
            tempId: tempId(),
            id: null,
            description: `Kopie von ${copysourcetitle}`
        })
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [copyAvailability]),
            { selectedAvailability: copyAvailability, stateChanged: true }
        ), () => {
            this.getValidationList()
        })
    }

    onSelectAvailability(availability) {
        if (availability || ! this.state.selectedAvailability) {
            this.setState({
                selectedAvailability: availability
            }, () => {
                this.getValidationList()
            })
        } else {
            this.setState({
                selectedAvailability: null
            })
        }
        
    }

    editExclusionAvailability(availability, startDate, endDate, description, kind) {
        (startDate) ? availability.startDate = startDate : null;
        (endDate) ? availability.endDate = endDate : null;
        availability.__modified = true;
        if (! availability.kind && kind != 'origin') {
            availability.tempId = tempId()
            availability.id = null
            availability.description = (description) ? description : availability.description
            availability.kind = kind
        } else {
            availability.kind = 'origin'
        }
        return availability;
    }

    onCreateExclusionForAvailability(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        const tomorrow = selectedDay.clone().add(1, 'days')

        this.isCreatingExclusion = true;

        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        let name = availability.description;
        name = name.replaceAll('Ausnahme zu Terminserie ', '');
        name = name.replaceAll('Fortführung der Terminserie ', '');

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        if (availability.startDate === selectedDay.unix()) {
            originAvailability.description = `Ausnahme zu Terminserie ` + name;
        }

        let exclusionAvailability = originAvailability;
        if (originAvailability.startDate < selectedDay.unix()) {
            exclusionAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10), 
                parseInt(selectedDay.unix(), 10),
                `Ausnahme zu Terminserie ` + name,
                'exclusion'
            )
        }

        let futureAvailability = originAvailability;
        if (parseInt(tomorrow.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(tomorrow.unix(), 10),
                null,
                `Fortführung der Terminserie ` + name,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                exclusionAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: exclusionAvailability, 
                stateChanged: true 
            }
        ), () => {
            console.log('in after merging', this.state.availabilitylist);
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onEditAvailabilityInFuture(availability) {
        const selectedDay = moment(this.props.timestamp, 'X').startOf('day')
        const yesterday = selectedDay.clone().subtract(1, 'days')
        let endDateTimestamp = (parseInt(yesterday.unix(), 10) < availability.startDate) ? 
            parseInt(selectedDay.unix(), 10) : 
            parseInt(yesterday.unix(), 10);

        const originAvailability = this.editExclusionAvailability(
            Object.assign({}, availability),
            null, 
            endDateTimestamp,
            null,
            'origin'
        )

        let futureAvailability = originAvailability;
        if (parseInt(selectedDay.unix(), 10) <= availability.endDate) {
            futureAvailability = this.editExclusionAvailability(
                Object.assign({}, availability),
                parseInt(selectedDay.unix(), 10),
                null,
                `Änderung ab ${formatTimestampDate(selectedDay)} (${availability.id})`,
                'future'
            )
        }

        this.setState(Object.assign({},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability,
                futureAvailability
            ]),
            { 
                selectedAvailability: futureAvailability, 
                stateChanged: true 
            }
        ), () => {
            this.getConflictList(),
            this.getValidationList()
        })
    }

    onNewAvailability() {
        let state = {};
        const newAvailability = getNewAvailability(this.props.timestamp, tempId(), this.props.scope);
        newAvailability.type = "appointment";
        
        // First update state with new availability
        state = Object.assign(
            state, 
            updateAvailabilityInState(this.state, newAvailability)
        );
        
        // Set selected availability for conflict checking
        state.selectedAvailability = newAvailability;
        state.stateChanged = false; // Keep save button disabled by default
        
        this.setState(state, () => {
            // After state is updated, check for validation errors and conflicts
            Promise.all([
                this.getValidationList(),
                this.getConflictList()
            ]).then(() => {
                // Clear selected availability after checks
                this.setState({ selectedAvailability: null });
            });
            
            $('body').scrollTop(0);
        });
    }

    onTabSelect(tab) {
        this.setState({ selectedTab: tab.component });
    }

    onConflictedIdSelect(id) {
        const availability = this.state.availabilitylist.filter(availability => availability.id === id)[0]
        if (availability) {
            this.setState({ selectedAvailability: availability })
        }
    }

    hasErrors(availability) {
        let hasError = false;
        let hasConflict = false;

        if (this.state.errorList) {
            Object.values(this.state.errorList).forEach(errorItem => {
                if (availability.id === errorItem.id)
                    hasError = true;
            });
        }

        if (this.state.conflictList && this.state.conflictList.conflictIdList) {
            this.state.conflictList.conflictIdList.forEach(id => {
                if (availability.id === id)
                    hasConflict = true;
            });
        }

        return hasError || hasConflict;
    }

    getValidationList() {
        return new Promise((resolve, reject) => {
            const validateData = (data) => {
                const validationResult = validate(data, this.props);
                if (!validationResult.valid) {
                    return validationResult.errorList;
                }
                return [];
            };
    
            const list = this.state.availabilitylist
                .map(validateData)
                .flat();
    
            console.log("Validation list:", list);
    
            this.setState(
                {
                    errorList: list.length ? list : [],
                },
                () => {
                    if (list.length > 0) {
                        console.warn("Validation failed with errors:", list);
                        this.errorElement?.scrollIntoView();
                    } else {
                        console.log("Validation passed.");
                        resolve();
                    }
                }
            );
        });
    }
    
    validateAvailabilityList(availabilitylist) {
        const timeRegex = /^\d{2}:\d{2}(:\d{2})?$/;
    
        const isValidTimestamp = (timestamp) => !Number.isNaN(Number(timestamp)) && moment.unix(timestamp).isValid();
    
        const invalidAvailabilities = availabilitylist.filter((availability) => {
            const hasInvalidDates =
                !isValidTimestamp(availability.startDate) || !isValidTimestamp(availability.endDate);
            const hasInvalidTimes =
                !timeRegex.test(availability.startTime) || !timeRegex.test(availability.endTime);
    
            if (hasInvalidDates || hasInvalidTimes) {
                console.warn("Invalid availability detected:", availability);
            }
    
            return hasInvalidDates || hasInvalidTimes;
        });
    
        return invalidAvailabilities;
    }

    getConflictList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, selectedAvailability } = this.state;
                const { timestamp } = this.props;
                if (selectedAvailability.kind === 'exclusion') {
                    console.log("Skipping exclusion..")
                    return;
                }
                const nonExclusionAvailabilities = availabilitylist.filter(a => a.kind !== 'exclusion');

                selectedAvailability.startTime = moment(selectedAvailability.startTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');
                selectedAvailability.endTime = moment(selectedAvailability.endTime, ['HH:mm:ss', 'HH:mm']).format('HH:mm');

                const requestOptions = {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        availabilityList: nonExclusionAvailabilities,
                        selectedDate: formatTimestampDate(timestamp),
                        selectedAvailability,
                    }),
                };

                const url = `${this.props.links.includeurl}/availability/conflicts/`;

                fetch(url, requestOptions)
                    .then((res) => res.json())
                    .then(
                        (data) => {
                            console.log("Conflicts fetched successfully:", data);
                            this.setState({
                                conflictList: {
                                    itemList: { ...data.conflictList },
                                    conflictIdList: data.conflictIdList,
                                },
                            });
                            if (data.conflictIdList.length > 0) {
                                this.errorElement?.scrollIntoView();
                            }
                        },
                        (err) => {
                            console.error("Conflict fetch error:", err);
                            hideSpinner();
                        }
                    );
            })
            .catch((error) => {
                console.warn("Validation failed. Conflict fetch aborted.", error);
            });
    }
    
    renderTimeTable() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const ViewComponent = this.state.selectedTab == 'graph' ? GraphView : TableView;
        return <ViewComponent
            timestamp={this.props.timestamp}
            conflicts={this.state.conflicts}
            availabilityList={this.state.availabilitylistslices || this.state.availabilitylist}
            data={this.state.selectedAvailability}
            maxWorkstationCount={this.state.maxWorkstationCount || this.props.maxworkstationcount}
            links={this.props.links}
            onSelect={onSelect}
            onDelete={onDelete}
            onAbort={this.onRevertUpdates.bind(this)}
            slotBuckets={this.state.slotbuckets}
        />
    }

    readCalculatedAvailabilityList() {
        this.getValidationList()
            .then(() => {
                const { availabilitylist, busyslots } = this.state;
    
                console.log("Validation passed. Proceeding with /availability/slots/.");
    
                $.ajax(`${this.props.links.includeurl}/availability/slots/`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    data: JSON.stringify({
                        availabilityList: availabilitylist,
                        busySlots: busyslots,
                    }),
                })
                    .done((responseData) => {
                        console.log("Slots fetched successfully:", responseData);
                        const availabilityList = writeSlotCalculationIntoAvailability(
                            this.state.availabilitylist,
                            responseData['maxSlots'],
                            responseData['busySlots']
                        );
                        this.setState({
                            availabilitylistslices: availabilityList,
                            maxWorkstationCount: parseInt(responseData['maxWorkstationCount']),
                        });
                    })
                    .fail((err) => {
                        console.error("Error during /availability/slots/ fetch:", err);
                        if (err.status === 404) {
                            console.log("404 error ignored.");
                            hideSpinner();
                        } else {
                            const isException = err.responseText.toLowerCase().includes("exception");
                            if (err.status >= 500 && isException) {
                                new ExceptionHandler($(".opened"), {
                                    code: err.status,
                                    message: err.responseText,
                                });
                            } else {
                                console.error("Unexpected error:", err.responseText);
                            }
                            hideSpinner();
                        }
                    });
            })
            .catch((error) => {
                console.warn("Validation failed. Slot calculation fetch aborted.", error);
                this.setState({ errorList: error });
                this.errorElement?.scrollIntoView();
            });
    }    
    
    handleChange(data) {
        if (data.__modified) {
            clearTimeout(this.timer)
            this.setState(
                Object.assign({}, updateAvailabilityInState(this.state, data)),
                () => {
                    this.readCalculatedAvailabilityList();
                    if (data.tempId || data.id) {
                        this.timer = setTimeout(() => {
                            this.getConflictList()
                            this.getValidationList()
                        }, this.waitintervall)
                    }
                }
            );
        }
        if (data.kind && inArray(data.kind, ["origin", "future", "exclusion"])) {
            this.handleChangesAvailabilityExclusion(data)
        }
    }

    handleChangesAvailabilityExclusion(data) {
        if ('origin' == data.kind && data.__modified) {
            this.handleOriginChanges(data)
        }
        if ('exclusion' == data.kind && data.__modified) {
            this.handleExclusionChanges(data)        
        }
        if ('future' == data.kind && data.__modified) {
            this.handleFutureChanges(data)
        }
    }

    handleOriginChanges(data) {
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: moment(data.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (exclusionAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                exclusionAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        }) : data;
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix()
        });
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [exclusionAvailability, futureAvailability, data])
        ));          
    }

    handleExclusionChanges(data) {
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const futureAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'future');

        const exclusionAvailability = Object.assign({}, data, {
            endDate: (data.startDate > data.endDate) ? data.startDate : data.endDate
        });

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        const futureAvailability = Object.assign({}, futureAvailabilityFromState, {
            startDate: moment(exclusionAvailability.endDate, 'X').startOf('day').add(1, 'days').unix(),
            endDate: (
                futureAvailabilityFromState.endDate > moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()) ?
                futureAvailabilityFromState.endDate :
                moment(data.endDate, 'X').startOf('day').add(1, 'days').unix()
        });

        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [
                originAvailability, 
                futureAvailability, 
                exclusionAvailability
            ])
        ));  
    }

    handleFutureChanges(data) {
        const startDate = moment(data.startDate, 'X').startOf('day').add(1, 'days').unix();
        const originAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'origin');
        const exclusionAvailabilityFromState = findAvailabilityInStateByKind(this.state, 'exclusion');
        
        const exclusionAvailability = (exclusionAvailabilityFromState) ? Object.assign({}, exclusionAvailabilityFromState, {
            startDate: (startDate < exclusionAvailabilityFromState.endDate) ? 
                parseInt(startDate, 10) : 
                exclusionAvailabilityFromState.endDate
        }) : data;

        const originAvailability = Object.assign({}, originAvailabilityFromState, {
            endDate: moment(exclusionAvailability.startDate, 'X').startOf('day').subtract(1, 'days').unix()
        });
    
        this.setState(Object.assign(
            {},
            mergeAvailabilityListIntoState(this.state, [originAvailability, exclusionAvailability, data])
        ));          
    }

    renderAvailabilityAccordion() {
        const onSelect = data => {
            this.onSelectAvailability(data)
        }
        const onCopy = data => {
            this.onCopyAvailability(data)
        }

        const onExclusion = data => {
            this.onCreateExclusionForAvailability(data)
        }

        const onEditInFuture = data => {
            this.onEditAvailabilityInFuture(data)
        }

        const onDelete = data => {
            this.onDeleteAvailability(data)
        }

        const onUpdateSingle = data => {
            this.onUpdateSingleAvailability(data)
        }

        const onNew = data => {
            this.onNewAvailability(data)
        }

        const handleChange = (data) => {
            this.handleChange(data)
        }

        return <AccordionLayout 
            availabilityList={this.state.availabilitylist}
            data={this.state.selectedAvailability}
            today={this.state.today}
            timestamp={this.props.timestamp}
            title=""
            onSelect={onSelect}
            onPublish={this.onPublishAvailability.bind(this)}
            onUpdateSingle={onUpdateSingle}
            onDelete={onDelete}
            onNew={onNew}
            onAbort={this.onRevertUpdates.bind(this)}
            onCopy={onCopy}
            onExclusion={onExclusion}
            onEditInFuture={onEditInFuture}
            handleChange={handleChange}
            stateChanged={this.state.stateChanged}
            includeUrl={this.props.links.includeurl}
            setErrorRef={this.setErrorRef}
            errorList={this.state.errorList ? 
                this.state.errorList : {}
            }
            conflictList={this.state.conflictList ? 
                this.state.conflictList : 
                {itemList: {}, conflictIdList: {}}
            }
            isCreatingExclusion={this.isCreatingExclusion}
        />
    }

    renderSaveBar() {
        if (this.state.lastSave) {
            return (
                <SaveBar 
                    lastSave={this.state.lastSave} 
                    success={this.state.saveSuccess} 
                    setSuccessRef={this.setSuccessRef} 
                />
            )
        }
    }    

    render() {
        return (
            <PageLayout
                tabs={<TabsBar selected={this.state.selectedTab} tabs={this.props.tabs} onSelect={this.onTabSelect.bind(this)} />}
                timeTable={this.renderTimeTable()}
                saveBar={this.renderSaveBar()}
                accordion={this.renderAvailabilityAccordion()}
                conflicts={<Conflicts conflicts={this.state.conflicts} onSelect={this.onConflictedIdSelect.bind(this)} />}
            />
        )
    }
}

AvailabilityPage.propTypes = {
    maxworkstationcount: PropTypes.number,
    timestamp: PropTypes.number,
    scope: PropTypes.object,
    links: PropTypes.object,
    tabs: PropTypes.array
}

export default AvailabilityPage

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fixed version of getNewAvailability that uses the correct source of availabilities:

export const getNewAvailability = (timestamp, tempId, scope, existingAvailabilities = []) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Filter availabilities for today
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    // Start from either 05:00 or current time, whichever is later
    const currentTime = moment()
    let startTime = moment('05:00:00', 'HH:mm:ss')
    if (currentTime.isAfter(startTime)) {
        // Round up to next half hour
        startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
    }
    let endTime = moment(startTime).add(1, 'hour')

    // Check if a time slot overlaps with any existing availability
    const hasOverlap = (start, end) => {
        return todayAvailabilities.some(availability => {
            const availStart = moment(availability.startTime, 'HH:mm:ss')
            const availEnd = moment(availability.endTime, 'HH:mm:ss')
            return !(end.isSameOrBefore(availStart) || start.isSameOrAfter(availEnd))
        })
    }

    // Try to find a slot before first availability
    if (todayAvailabilities.length > 0) {
        const firstAvail = todayAvailabilities[0]
        const firstStart = moment(firstAvail.startTime, 'HH:mm:ss')
        
        // If we can fit a slot before the first availability
        if (!hasOverlap(startTime, endTime) && endTime.isSameOrBefore(firstStart)) {
            // Use this slot
        } else {
            // Try after the last availability
            const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
            startTime = moment(lastAvail.endTime, 'HH:mm:ss')
            // Ensure we don't create slots in the past
            if (startTime.isBefore(currentTime)) {
                startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
            }
            endTime = moment(startTime).add(1, 'hour')
        }
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

And update the call in index.js:

onNewAvailability() {
    let state = {};
    const newAvailability = getNewAvailability(
        this.props.timestamp, 
        tempId(), 
        this.props.scope,
        this.state.availabilitylist // Pass existing availabilities
    );
    newAvailability.type = "appointment";
    
    state = Object.assign(
        state, 
        updateAvailabilityInState(this.state, newAvailability)
    );
    
    state.selectedAvailability = newAvailability;
    state.stateChanged = false;
    
    this.setState(state, () => {
        Promise.all([
            this.getValidationList(),
            this.getConflictList()
        ]).then(() => {
            this.setState({ selectedAvailability: null });
        });
        
        $('body').scrollTop(0);
    });
}

Key changes:

  1. Added existingAvailabilities parameter to getNewAvailability
  2. Pass this.state.availabilitylist when calling getNewAvailability
  3. Use passed availabilities instead of looking in scope

This should now correctly detect existing availabilities and place new slots appropriately.


✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T18:15:12.124Z
Learning: In the eappointment system, when creating new availabilities, pass existing availabilities from the component's state rather than looking for them in the scope object, as they are managed in the component's state.availabilitylist.
🔗 Analysis chain
🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Find where scope.availabilitylist is used/populated
rg -A 5 "scope\.availabilitylist" --type js

# Find where getNewAvailability is called
rg -A 5 "getNewAvailability" --type js

# Find any console.log statements around availability creation
rg "console\.log.*availability" --type js

Length of output: 2189

Copy link
Contributor

coderabbitai bot commented Jan 28, 2025

Tip

For best results, initiate chat on the files or code changes.

@ThomasAFink Here's the fixed getNewAvailability() function:

export const getNewAvailability = (timestamp, tempId, scope) => {
    const now = moment(timestamp, 'X')
    const weekday = [
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
        'sunday'
    ][now.isoWeekday() - 1]

    // Get all availabilities for this date
    const existingAvailabilities = scope.availabilitylist || []
    const todayAvailabilities = existingAvailabilities.filter(a => 
        moment(a.startDate, 'X').format('YYYY-MM-DD') === now.format('YYYY-MM-DD')
    )

    // Sort by start time
    todayAvailabilities.sort((a, b) => {
        const aTime = moment(a.startTime, 'HH:mm:ss')
        const bTime = moment(b.startTime, 'HH:mm:ss') 
        return aTime.diff(bTime)
    })

    console.log('Today availabilities:', todayAvailabilities.map(a => ({
        start: a.startTime,
        end: a.endTime
    })))

    // Start from either 05:00 or current time, whichever is later
    const currentTime = moment()
    let startTime = moment('05:00:00', 'HH:mm:ss')
    if (currentTime.isAfter(startTime)) {
        // Round up to next half hour
        startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
    }
    let endTime = moment(startTime).add(1, 'hour')

    // Check if a time slot overlaps with any existing availability
    const hasOverlap = (start, end) => {
        return todayAvailabilities.some(availability => {
            const availStart = moment(availability.startTime, 'HH:mm:ss')
            const availEnd = moment(availability.endTime, 'HH:mm:ss')
            return !(end.isSameOrBefore(availStart) || start.isSameOrAfter(availEnd))
        })
    }

    // Try to find a slot before first availability
    if (todayAvailabilities.length > 0) {
        const firstAvail = todayAvailabilities[0]
        const firstStart = moment(firstAvail.startTime, 'HH:mm:ss')
        
        // If we can fit a slot before the first availability
        if (!hasOverlap(startTime, endTime) && endTime.isSameOrBefore(firstStart)) {
            console.log('Using slot before first availability:', startTime.format('HH:mm'), '-', endTime.format('HH:mm'))
        } else {
            // Try after the last availability
            const lastAvail = todayAvailabilities[todayAvailabilities.length - 1]
            startTime = moment(lastAvail.endTime, 'HH:mm:ss')
            // Ensure we don't create slots in the past
            if (startTime.isBefore(currentTime)) {
                startTime = moment(currentTime).add(30 - (currentTime.minutes() % 30), 'minutes')
            }
            endTime = moment(startTime).add(1, 'hour')
            
            // If this slot also overlaps, try next day
            if (hasOverlap(startTime, endTime)) {
                console.log('No available slots today, using next day')
                startTime = moment('05:00:00', 'HH:mm:ss')
                endTime = moment(startTime).add(1, 'hour')
            } else {
                console.log('Using slot after last availability:', startTime.format('HH:mm'), '-', endTime.format('HH:mm'))
            }
        }
    }

    return {
        id: null,
        tempId,
        scope: Object.assign({}, scope),
        description: 'Neue Öffnungszeit',
        startDate: timestamp,
        endDate: timestamp,
        startTime: startTime.format('HH:mm:ss'),
        endTime: endTime.format('HH:mm:ss'),
        bookable: {
            startInDays: scope.preferences.appointment.startInDaysDefault ?? 0,
            endInDays: scope.preferences.appointment.endInDaysDefault ?? 0
        },
        multipleSlotsAllowed: 1,
        slotTimeInMinutes: scope.provider.data['slotTimeInMinutes'],
        weekday: {
            [weekday]: 1
        },
        workstationCount: {
            intern: 0,
            callcenter: 0,
            'public': 0
        },
        repeat: {
            afterWeeks: 0,
            weekOfMonth: 0
        },
        type: null,
        kind: "new"
    }
}

Key changes:

  1. Added debug logging to track slot selection
  2. Fixed logic to properly handle overlapping slots
  3. Added fallback to next day at 05:00 if no slots available today
  4. Improved comments for better maintainability

This should now properly avoid overlapping with existing availabilities.

✏️ Learnings added
Learnt from: ThomasAFink
URL: https://github.com/it-at-m/eappointment/pull/799

Timestamp: 2025-01-27T18:15:12.124Z
Learning: When creating new availabilities in the eappointment system, if no non-overlapping slots are available for the current day (either before first availability or after last availability), default to 05:00-06:00 on the next day.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant