org.openvpms.web.workspace.workflow.appointment.AppointmentCRUDWindow.java Source code

Java tutorial

Introduction

Here is the source code for org.openvpms.web.workspace.workflow.appointment.AppointmentCRUDWindow.java

Source

/*
 * Version: 1.0
 *
 * The contents of this file are subject to the OpenVPMS License Version
 * 1.0 (the 'License'); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.openvpms.org/license/
 *
 * Software distributed under the License is distributed on an 'AS IS' basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * Copyright 2019 (C) OpenVPMS Ltd. All Rights Reserved.
 */

package org.openvpms.web.workspace.workflow.appointment;

import echopointng.KeyStrokes;
import nextapp.echo2.app.Button;
import nextapp.echo2.app.event.ActionEvent;
import org.joda.time.DateTime;
import org.joda.time.Minutes;
import org.openvpms.archetype.rules.user.UserArchetypes;
import org.openvpms.archetype.rules.util.DateRules;
import org.openvpms.archetype.rules.util.DateUnits;
import org.openvpms.archetype.rules.workflow.AppointmentRules;
import org.openvpms.archetype.rules.workflow.AppointmentStatus;
import org.openvpms.archetype.rules.workflow.ScheduleArchetypes;
import org.openvpms.component.business.domain.im.act.Act;
import org.openvpms.component.business.domain.im.party.Contact;
import org.openvpms.component.business.domain.im.party.Party;
import org.openvpms.component.business.domain.im.security.User;
import org.openvpms.component.business.service.archetype.helper.ActBean;
import org.openvpms.component.business.service.archetype.helper.IMObjectBean;
import org.openvpms.component.business.service.archetype.helper.TypeHelper;
import org.openvpms.component.exception.OpenVPMSException;
import org.openvpms.component.model.entity.Entity;
import org.openvpms.component.system.common.util.PropertySet;
import org.openvpms.hl7.patient.PatientContext;
import org.openvpms.hl7.patient.PatientInformationService;
import org.openvpms.web.component.app.Context;
import org.openvpms.web.component.app.LocalContext;
import org.openvpms.web.component.im.archetype.Archetypes;
import org.openvpms.web.component.im.contact.ContactHelper;
import org.openvpms.web.component.im.edit.IMObjectEditor;
import org.openvpms.web.component.im.layout.DefaultLayoutContext;
import org.openvpms.web.component.im.layout.LayoutContext;
import org.openvpms.web.component.im.query.TabbedBrowserListener;
import org.openvpms.web.component.im.sms.SMSDialog;
import org.openvpms.web.component.im.sms.SMSHelper;
import org.openvpms.web.component.im.util.IMObjectHelper;
import org.openvpms.web.component.im.view.Selection;
import org.openvpms.web.component.util.ErrorHelper;
import org.openvpms.web.component.workflow.DefaultTaskListener;
import org.openvpms.web.component.workflow.TaskEvent;
import org.openvpms.web.component.workflow.Workflow;
import org.openvpms.web.echo.button.ButtonSet;
import org.openvpms.web.echo.dialog.InformationDialog;
import org.openvpms.web.echo.dialog.PopupDialogListener;
import org.openvpms.web.echo.event.ActionListener;
import org.openvpms.web.echo.factory.ButtonFactory;
import org.openvpms.web.echo.help.HelpContext;
import org.openvpms.web.resource.i18n.Messages;
import org.openvpms.web.system.ServiceHelper;
import org.openvpms.web.workspace.patient.info.PatientContextHelper;
import org.openvpms.web.workspace.workflow.LocalClinicianContext;
import org.openvpms.web.workspace.workflow.WorkflowFactory;
import org.openvpms.web.workspace.workflow.appointment.reminder.AppointmentReminderEvaluator;
import org.openvpms.web.workspace.workflow.appointment.repeat.RepeatCondition;
import org.openvpms.web.workspace.workflow.appointment.repeat.RepeatExpression;
import org.openvpms.web.workspace.workflow.appointment.repeat.ScheduleEventSeriesState;
import org.openvpms.web.workspace.workflow.checkin.TransferWorkflow;
import org.openvpms.web.workspace.workflow.scheduling.ScheduleCRUDWindow;

import java.util.Date;
import java.util.List;

/**
 * Appointment CRUD window.
 *
 * @author Tim Anderson
 */
public class AppointmentCRUDWindow extends ScheduleCRUDWindow {

    /**
     * The browser.
     */
    private final AppointmentBrowser browser;

    /**
     * The rules.
     */
    private final AppointmentRules rules;

    /**
     * The original status of the appointment being edited.
     */
    private String oldStatus;

    /**
     * New schedule block button identifier.
     */
    private static final String BLOCK_ID = "button.block";

    /**
     * Check-in button identifier.
     */
    private static final String CHECKIN_ID = "button.checkin";

    /**
     * SMS reminder button identifier.
     */
    private static final String REMIND_ID = "button.sms.remind";

    /**
     * The transfer button.
     */
    private static final String TRANSFER_ID = "button.transfer";

    /**
     * The schedule block archetype.
     */
    private static final Archetypes<Act> BLOCK = Archetypes.create(ScheduleArchetypes.CALENDAR_BLOCK, Act.class);

    /**
     * Constructs an {@link AppointmentCRUDWindow}.
     *
     * @param browser the browser
     * @param context the context
     * @param help    the help context
     */
    public AppointmentCRUDWindow(AppointmentBrowser browser, Context context, HelpContext help) {
        this(browser, AppointmentActions.INSTANCE, context, help);
    }

    /**
     * Constructs an {@link AppointmentCRUDWindow}.
     *
     * @param browser the browser
     * @param context the context
     * @param help    the help context
     */
    protected AppointmentCRUDWindow(AppointmentBrowser browser, AppointmentActions actions, Context context,
            HelpContext help) {
        super(Archetypes.create(ScheduleArchetypes.APPOINTMENT, Act.class,
                Messages.get("workflow.scheduling.createtype")), actions, context, help);
        this.browser = browser;
        browser.setListener(new TabbedBrowserListener() {
            @Override
            public void onBrowserChanged() {
                enableButtons(getButtons(), getObject() != null);
            }
        });
        rules = ServiceHelper.getBean(AppointmentRules.class);
    }

    /**
     * Sets the object.
     *
     * @param object the object. May be {@code null}
     */
    @Override
    public void setObject(Act object) {
        super.setObject(object);
        getContext().setAppointment(object); // make available to macros etc
    }

    /**
     * Creates and edits a new appointment, if a slot has been selected.
     */
    @Override
    public void create() {
        if (canCreateAppointment()) {
            super.create();
        }
    }

    /**
     * Deletes an object.
     *
     * @param object the object to delete
     */
    @Override
    protected void delete(final Act object) {
        final ScheduleEventSeriesState state = new ScheduleEventSeriesState(object,
                ServiceHelper.getArchetypeService());
        if (state.hasSeries() && state.canEditFuture()) {
            final DeleteSeriesDialog dialog = new DeleteSeriesDialog(state,
                    getHelpContext().subtopic("deleteseries"));
            dialog.addWindowPaneListener(new PopupDialogListener() {
                @Override
                public void onOK() {
                    boolean deleted = false;
                    if (dialog.single()) {
                        deleted = state.delete();
                    } else if (dialog.future()) {
                        deleted = state.deleteFuture();
                    } else if (dialog.all()) {
                        deleted = state.deleteSeries();
                    }
                    if (deleted) {
                        onDeleted(object);
                    }
                }
            });
            dialog.show();
        } else {
            super.delete(object);
        }
    }

    /**
     * Determines the actions that may be performed on the selected object.
     *
     * @return the actions
     */
    @Override
    protected AppointmentActions getActions() {
        return (AppointmentActions) super.getActions();
    }

    /**
     * Edits an object.
     *
     * @param object the object to edit
     * @param path   the selection path. May be {@code null}
     */
    @Override
    protected void edit(final Act object, final List<Selection> path) {
        oldStatus = object.getStatus();
        final ScheduleEventSeriesState state = new ScheduleEventSeriesState(object,
                ServiceHelper.getArchetypeService());
        if (state.hasSeries()) {
            if (state.canEditFuture()) {
                final EditSeriesDialog dialog = new EditSeriesDialog(state,
                        getHelpContext().subtopic("editseries"));
                dialog.addWindowPaneListener(new PopupDialogListener() {
                    @Override
                    public void onOK() {
                        if (dialog.single()) {
                            edit(object, path, false);
                        } else if (dialog.future()) {
                            edit(object, path, true);
                        } else if (dialog.all()) {
                            edit(state.getFirst(), path, true);
                        }
                    }
                });
                dialog.show();
            } else {
                // can't edit the future appointments, so disable series editing
                edit(object, path, false);
            }
        } else {
            // not part of a series, so enable series editing
            edit(object, path, true);
        }
    }

    /**
     * Edits an event.
     *
     * @param object     the event to edit
     * @param path       the selection path. May be {@code null}
     * @param editSeries if {@code true}, edit the series, otherwise edit the event
     */
    protected void edit(Act object, List<Selection> path, boolean editSeries) {
        try {
            HelpContext edit = createEditTopic(object);
            LayoutContext context = createLayoutContext(edit);
            IMObjectEditor editor;
            if (isAppointment(object)) {
                editor = new AppointmentEditor(object, null, editSeries, context);
            } else {
                editor = new CalendarBlockEditor(object, null, editSeries, context);
            }
            editor.getComponent();
            edit(editor, path);
        } catch (OpenVPMSException exception) {
            ErrorHelper.show(exception);
        }
    }

    /**
     * Edits an object.
     *
     * @param editor the object editor
     * @param path   the selection path. May be {@code null}
     * @return the edit dialog
     */
    @Override
    protected AbstractCalendarEventEditDialog edit(IMObjectEditor editor, List<Selection> path) {
        Date startTime = browser.getSelectedTime();
        if (startTime != null && editor.getObject().isNew() && editor instanceof AbstractCalendarEventEditor) {
            ((AbstractCalendarEventEditor) editor).setStartTime(startTime);
        }
        return (AbstractCalendarEventEditDialog) super.edit(editor, path);
    }

    /**
     * Invoked when the object has been saved.
     *
     * @param object the object
     * @param isNew  determines if the object is a new instance
     */
    @Override
    protected void onSaved(Act object, boolean isNew) {
        super.onSaved(object, isNew);
        String newStatus = object.getStatus();
        User user = getContext().getUser();
        if (isAppointment(object)) {
            if (!AppointmentStatus.CANCELLED.equals(oldStatus) && AppointmentStatus.CANCELLED.equals(newStatus)) {
                PatientContext context = getPatientContext(object);
                if (context != null) {
                    PatientInformationService service = ServiceHelper.getBean(PatientInformationService.class);
                    service.admissionCancelled(context, user);
                }
            } else if (!isAdmitted(oldStatus) && isAdmitted(newStatus)) {
                PatientContext context = getPatientContext(object);
                if (context != null) {
                    PatientInformationService service = ServiceHelper.getBean(PatientInformationService.class);
                    service.admitted(context, user);
                }
            } else if (isAdmitted(oldStatus) && !isAdmitted(newStatus)) {
                PatientContext context = getPatientContext(object);
                if (context != null) {
                    PatientInformationService service = ServiceHelper.getBean(PatientInformationService.class);
                    service.discharged(context, user);
                }
            }
        }
    }

    /**
     * Lays out the buttons.
     *
     * @param buttons the button row
     */
    @Override
    protected void layoutButtons(ButtonSet buttons) {
        super.layoutButtons(buttons);
        Button checkIn = ButtonFactory.create(CHECKIN_ID, new ActionListener() {
            public void onAction(ActionEvent event) {
                onCheckIn();
            }
        });
        buttons.add(checkIn);
        buttons.add(createConsultButton());
        buttons.add(createCheckOutButton());
        buttons.add(TRANSFER_ID, new ActionListener() {
            public void onAction(ActionEvent event) {
                onTransfer();
            }
        });
        buttons.add(createOverTheCounterButton());
        buttons.add(createFlowSheetButton());
        buttons.addKeyListener(KeyStrokes.CONTROL_MASK | 'C', new ActionListener() {
            public void onAction(ActionEvent event) {
                onCopy();
            }
        });
        buttons.addKeyListener(KeyStrokes.CONTROL_MASK | 'X', new ActionListener() {
            public void onAction(ActionEvent event) {
                onCut();
            }
        });
        buttons.addKeyListener(KeyStrokes.CONTROL_MASK | 'V', new ActionListener() {
            public void onAction(ActionEvent event) {
                onPaste();
            }
        });
        if (SMSHelper.isSMSEnabled(getContext().getPractice())) {
            buttons.add(ButtonFactory.create(REMIND_ID, new ActionListener() {
                @Override
                public void onAction(ActionEvent event) {
                    onSMS();
                }
            }));
        }
        buttons.add(BLOCK_ID, new ActionListener() {
            @Override
            public void onAction(ActionEvent event) {
                onBlock();
            }
        });
    }

    /**
     * Enables/disables the buttons that require an object to be selected.
     *
     * @param buttons the button set
     * @param enable  determines if buttons should be enabled
     */
    @Override
    protected void enableButtons(ButtonSet buttons, boolean enable) {
        enable = browser.isAppointmentsSelected() && enable;
        super.enableButtons(buttons, enable);
        boolean checkInEnabled = false;
        boolean checkoutConsultEnabled = false;
        boolean transferEnabled = false;
        boolean smsEnabled = false;
        boolean printEnabled = false;
        if (enable) {
            Act act = getObject();
            AppointmentActions actions = getActions();
            if (actions.canCheckIn(act)) {
                checkInEnabled = true;
                checkoutConsultEnabled = false;
            } else if (actions.canCheckoutOrConsult(act)) {
                checkInEnabled = false;
                checkoutConsultEnabled = true;
            }
            transferEnabled = actions.canTransfer(act);
            smsEnabled = actions.canSMS(act);
            printEnabled = getActions().isAppointment(act);
        }
        buttons.setEnabled(NEW_ID, canCreateAppointment());
        enablePrintPreview(buttons, printEnabled);
        buttons.setEnabled(CHECKIN_ID, checkInEnabled);
        buttons.setEnabled(CONSULT_ID, checkoutConsultEnabled);
        buttons.setEnabled(CHECKOUT_ID, checkoutConsultEnabled);
        buttons.setEnabled(TRANSFER_ID, transferEnabled);
        buttons.setEnabled(OVER_THE_COUNTER_ID, browser.isAppointmentsSelected());
        buttons.setEnabled(REMIND_ID, smsEnabled);
    }

    /**
     * Creates a layout context for editing an object.
     *
     * @param help the help context
     * @return a new layout context.
     */
    @Override
    protected LayoutContext createLayoutContext(HelpContext help) {
        // create a local context - don't want to pick up the current clinician
        Context local = new LocalClinicianContext(getContext());
        return new DefaultLayoutContext(true, local, help);
    }

    /**
     * Determines if an appointment can be created.
     *
     * @return {@code true} if a schedule and slot has been selected
     */
    private boolean canCreateAppointment() {
        return browser.isAppointmentsSelected() && browser.getSelectedSchedule() != null
                && browser.getSelectedTime() != null;
    }

    /**
     * Invoked when the 'check-in' button is pressed.
     */
    private void onCheckIn() {
        Act act = IMObjectHelper.reload(getObject());
        // make sure the act is still available and can be checked in prior to beginning workflow
        if (act != null && getActions().canCheckIn(act)) {
            WorkflowFactory factory = ServiceHelper.getBean(WorkflowFactory.class);
            Workflow workflow = factory.createCheckInWorkflow(act, getContext(), getHelpContext());
            workflow.addTaskListener(new DefaultTaskListener() {
                public void taskEvent(TaskEvent event) {
                    onRefresh(getObject());
                }
            });
            workflow.start();
        } else {
            onRefresh(getObject());
        }
    }

    /**
     * Invoked when the 'transfer' button is pressed.
     */
    private void onTransfer() {
        Act act = IMObjectHelper.reload(getObject());
        if (act != null && getActions().canTransfer(act)) {
            TransferWorkflow workflow = new TransferWorkflow(act, getContext(), getHelpContext());
            workflow.start();
        } else {
            onRefresh(getObject());
        }
    }

    /**
     * Invoked to copy an appointment.
     */
    private void onCopy() {
        if (browser.isAppointmentsSelected()) {
            browser.clearMarked();
            PropertySet selected = browser.getSelected();
            Act appointment = browser.getAct(selected);
            if (appointment != null) {
                browser.setMarked(selected, false);
            } else {
                InformationDialog.show(Messages.get("workflow.scheduling.appointment.copy.title"),
                        Messages.get("workflow.scheduling.appointment.copy.select"));
            }
        }
    }

    /**
     * Invoked to cut an appointment.
     */
    private void onCut() {
        if (browser.isAppointmentsSelected()) {
            browser.clearMarked();
            PropertySet selected = browser.getSelected();
            Act event = browser.getAct(selected);
            if (event != null) {
                if (TypeHelper.isA(event, ScheduleArchetypes.CALENDAR_BLOCK)
                        || AppointmentStatus.PENDING.equals(event.getStatus())) {
                    browser.setMarked(selected, true);
                } else {
                    InformationDialog.show(Messages.get("workflow.scheduling.appointment.cut.title"),
                            Messages.get("workflow.scheduling.appointment.cut.pending"));
                }
            } else {
                InformationDialog.show(Messages.get("workflow.scheduling.appointment.cut.title"),
                        Messages.get("workflow.scheduling.appointment.cut.select"));
            }
        }
    }

    /**
     * Invoked to paste an act.
     * <p/>
     * For the paste to be successful:
     * <ul>
     * <li>the act must still exist
     * <li>for cut appointments, the appointment must be PENDING
     * <li>a schedule must be selected
     * <li>a time slot must be selected
     * </ul>
     */
    private void onPaste() {
        if (browser.isAppointmentsSelected()) {
            if (browser.getMarked() == null) {
                InformationDialog.show(Messages.get("workflow.scheduling.appointment.paste.title"),
                        Messages.get("workflow.scheduling.appointment.paste.select"));
            } else {
                final Act act = browser.getAct(browser.getMarked());
                final Entity schedule = browser.getSelectedSchedule();
                final Date startTime = browser.getSelectedTime();
                if (act == null) {
                    InformationDialog.show(Messages.get("workflow.scheduling.appointment.paste.title"),
                            Messages.get("workflow.scheduling.appointment.paste.noexist"));
                    onRefresh((Act) null); // force redraw
                    browser.clearMarked();
                } else if (browser.isCut() && TypeHelper.isA(act, ScheduleArchetypes.APPOINTMENT)
                        && !AppointmentStatus.PENDING.equals(act.getStatus())) {
                    InformationDialog.show(Messages.get("workflow.scheduling.appointment.paste.title"),
                            Messages.get("workflow.scheduling.appointment.paste.pending"));
                    onRefresh(act); // force redraw
                    browser.clearMarked();
                } else if (schedule == null || startTime == null) {
                    InformationDialog.show(Messages.get("workflow.scheduling.appointment.paste.title"),
                            Messages.get("workflow.scheduling.appointment.paste.noslot"));
                } else {
                    final ScheduleEventSeriesState state = new ScheduleEventSeriesState(act,
                            ServiceHelper.getArchetypeService());
                    HelpContext help = getHelpContext();
                    if (browser.isCut()) {
                        if (state.hasSeries() && state.canEditFuture()) {
                            final MoveSeriesDialog dialog = new MoveSeriesDialog(state,
                                    help.subtopic("moveseries"));
                            dialog.addWindowPaneListener(new PopupDialogListener() {
                                @Override
                                public void onOK() {
                                    if (dialog.single()) {
                                        cut(act, schedule, startTime, null);
                                    } else if (dialog.future()) {
                                        cut(act, schedule, startTime, state);
                                    } else if (dialog.all()) {
                                        cut(state.getFirst(), schedule, startTime, state);
                                    }
                                }
                            });
                            dialog.show();
                        } else {
                            cut(act, schedule, startTime, null);
                        }
                    } else {
                        if (state.hasSeries() && state.canEditFuture()) {
                            final CopySeriesDialog dialog = new CopySeriesDialog(state,
                                    help.subtopic("copyseries"));
                            dialog.addWindowPaneListener(new PopupDialogListener() {
                                @Override
                                public void onOK() {
                                    if (dialog.single()) {
                                        copy(act, schedule, startTime, null, 0);
                                    } else if (dialog.future()) {
                                        copy(act, schedule, startTime, state, state.getIndex());
                                    } else if (dialog.all()) {
                                        copy(state.getFirst(), schedule, startTime, state, 0);
                                    }
                                }
                            });
                            dialog.show();
                        } else {
                            copy(act, schedule, startTime, null, 0);
                        }
                    }
                }
            }
        }
    }

    /**
     * Invoked to send an SMS reminder for the selected appointment.
     */
    private void onSMS() {
        final Act object = IMObjectHelper.reload(getObject());
        if (object != null) {
            final ActBean bean = new ActBean(object);
            Party customer = (Party) bean.getNodeParticipant("customer");
            Party patient = (Party) bean.getNodeParticipant("patient");
            Party location = getLocation(bean);
            Context context = getContext();

            final List<Contact> contacts = ContactHelper.getSMSContacts(customer);
            if (!contacts.isEmpty() && location != null) {
                final Context local = new LocalContext(context);
                local.setCustomer(customer);
                local.setPatient(patient);
                Entity template = SMSHelper.getAppointmentTemplate(location);
                SMSDialog dialog = new SMSDialog(contacts, local, getHelpContext().subtopic("sms"));
                dialog.show();
                if (template != null) {
                    try {
                        AppointmentReminderEvaluator evaluator = ServiceHelper
                                .getBean(AppointmentReminderEvaluator.class);
                        String message = evaluator.evaluate(template, object, location, context.getPractice());
                        dialog.setMessage(message);
                    } catch (Throwable exception) {
                        ErrorHelper.show(exception);
                    }
                }
                dialog.addWindowPaneListener(new PopupDialogListener() {
                    @Override
                    public void onOK() {
                        bean.setValue("reminderSent", new Date());
                        bean.setValue("reminderError", null);
                        bean.save();
                        onSaved(object, false);
                    }
                });
            } else if (contacts.isEmpty()) {
                InformationDialog.show(Messages.get("sms.appointment.nocontact"));
            } else {
                InformationDialog.show(Messages.get("sms.appointment.nolocation"));
            }
        } else {
            onRefresh(getObject());
        }
    }

    /**
     * Creates and edits a new schedule block, if a slot has been selected.
     */
    private void onBlock() {
        if (canCreateAppointment()) {
            onCreate(BLOCK);
        }
    }

    /**
     * Returns the location associated with an appointment.
     *
     * @param bean the appointment bean
     * @return the location, or {@code null} if one cannot be found
     */
    private Party getLocation(ActBean bean) {
        Entity schedule = bean.getNodeParticipant("schedule");
        if (schedule != null) {
            IMObjectBean scheduleBean = new IMObjectBean(schedule);
            return (Party) scheduleBean.getNodeTargetObject("location");
        }
        return null;
    }

    /**
     * Cuts an act and pastes it to the specified schedule and start time.
     * <p/>
     * For appointments, if the appointment is being moved to a different day, and a reminder has already been sent,
     * the reminder status is reset.
     *
     * @param act       the act
     * @param schedule  the new schedule
     * @param startTime the new start time
     * @param series    the appointment series. May be {@code null}
     */
    private void cut(Act act, Entity schedule, Date startTime, ScheduleEventSeriesState series) {
        if (isAppointment(act)) {
            if (DateRules.compareTo(act.getActivityStartTime(), startTime) != 0) {
                ActBean bean = new ActBean(act);
                bean.setValue("reminderSent", null);
                bean.setValue("reminderError", null);
            }
        }
        int duration = getDuration(act.getActivityStartTime(), act.getActivityEndTime());
        paste(act, schedule, startTime, duration, series, false, null, null);
        browser.clearMarked();
    }

    /**
     * Copies an act and pastes it to the specified schedule and start time.
     *
     * @param act       the act
     * @param schedule  the new schedule
     * @param startTime the new start time
     * @param series    the appointment series. May be {@code null}
     * @param index     the index of the appointment in the series
     */
    private void copy(Act act, Entity schedule, Date startTime, ScheduleEventSeriesState series, int index) {
        int duration = getDuration(act.getActivityStartTime(), act.getActivityEndTime());
        act = rules.copy(act);
        ActBean bean = new ActBean(act);
        if (isAppointment(act)) {
            bean.setValue("status", AppointmentStatus.PENDING);
            bean.setValue("arrivalTime", null);
            bean.setValue("reminderSent", null);
            bean.setValue("reminderError", null);
        }
        bean.setParticipant(UserArchetypes.AUTHOR_PARTICIPATION, getContext().getUser());
        RepeatExpression expression = (series != null) ? series.getExpression() : null;
        RepeatCondition condition = (series != null) ? series.getCondition(index) : null;
        paste(act, schedule, startTime, duration, series, true, expression, condition);
    }

    /**
     * Pastes an act to the specified schedule and start time.
     *
     * @param act        the act
     * @param schedule   the new schedule
     * @param startTime  the new start time
     * @param duration   the duration of the act, in minutes
     * @param series     the appointment series. May be {@code null}
     * @param copy       if {@code true}, the act is being copied, otherwise it is being moved
     * @param expression the new repeat expression. Only relevant if the series is being copied. May be {@code null}
     * @param condition  the new repeat condition. Only relevant if the series is being copied. May be {@code null}
     */
    private void paste(Act act, Entity schedule, Date startTime, int duration, ScheduleEventSeriesState series,
            boolean copy, RepeatExpression expression, RepeatCondition condition) {
        HelpContext edit = createEditTopic(act);
        LocalContext localContext = LocalContext.copy(getContext());
        localContext.setCustomer(null); // make sure customer, patient, and clinician aren't inherited
        localContext.setPatient(null); // if they aren't populated
        localContext.setClinician(null);
        DefaultLayoutContext context = new DefaultLayoutContext(localContext, edit);
        AbstractCalendarEventEditor editor = createEditor(act, series, context);
        AbstractCalendarEventEditDialog dialog = edit(editor, null);
        // NOTE: need to update the start time after dialog is created
        //       See CalendarEventEditDialog.timesModified().
        editor.setSchedule(schedule);
        editor.setStartTime(startTime); // will recalc end time. May be rounded to nearest slot
        startTime = editor.getStartTime();
        Date endTime = editor.getEndTime();
        if (endTime != null) {
            // if the new act is shorter than the old, try and adjust it
            int newLength = getDuration(editor.getStartTime(), endTime);
            if (newLength < duration) {
                editor.setEndTime(DateRules.getDate(startTime, duration, DateUnits.MINUTES));
            }
        }
        if (copy) {
            editor.setExpression(expression);
            editor.setCondition(condition);
        } else {
            editor.getSeries().setUpdateTimesOnly(true);
        }
        dialog.setAlwaysCheckOverlap(true); // checks for overlapping appointments
        dialog.save(true);
        browser.setSelected(browser.getEvent(act));
    }

    private AbstractCalendarEventEditor createEditor(Act act, ScheduleEventSeriesState series,
            DefaultLayoutContext context) {
        AbstractCalendarEventEditor result;
        if (isAppointment(act)) {
            result = new AppointmentEditor(act, null, series != null, context);
        } else {
            result = new CalendarBlockEditor(act, null, series != null, context);
        }
        return result;
    }

    /**
     * Returns the duration in minutes between two times.
     *
     * @param startTime the start time
     * @param endTime   the end time
     * @return the duration in minutes
     */
    private int getDuration(Date startTime, Date endTime) {
        return Minutes.minutesBetween(new DateTime(startTime), new DateTime(endTime)).getMinutes();
    }

    /**
     * Returns the patient context for an appointment.
     *
     * @param appointment the appointment
     * @return the patient context, or {@code null} if the patient can't be found, or has no current visit
     */
    private PatientContext getPatientContext(Act appointment) {
        return PatientContextHelper.getAppointmentContext(appointment, getContext());
    }

    /**
     * Determines if an appointment status indicates the patient has been admitted.
     *
     * @param status the appointment status
     * @return {@code true} if the patient has been admitted
     */
    private boolean isAdmitted(String status) {
        return AppointmentStatus.CHECKED_IN.equals(status) || AppointmentStatus.ADMITTED.equals(status)
                || AppointmentStatus.IN_PROGRESS.equals(status) || AppointmentStatus.BILLED.equals(status);
    }

    /**
     * Determines if an object is an appointment.
     *
     * @param object the object
     * @return {@code true} if it is an appointment
     */
    private boolean isAppointment(Act object) {
        return getActions().isAppointment(object);
    }

    protected static class AppointmentActions extends ScheduleActions {

        public static AppointmentActions INSTANCE = new AppointmentActions();

        /**
         * Determines if an act is an appointment.
         *
         * @param act the act
         * @return {@code true} if it is an appointment
         */
        public boolean isAppointment(Act act) {
            return act.isA(ScheduleArchetypes.APPOINTMENT);
        }

        /**
         * Determines if an appointment can be checked in.
         * <p/>
         * The appointment must be {@link AppointmentStatus#PENDING}, and have a customer assigned.
         *
         * @param act the appointment
         * @return {@code true} if it can be checked in
         */
        public boolean canCheckIn(Act act) {
            return isAppointment(act) && AppointmentStatus.PENDING.equals(act.getStatus())
                    && new ActBean(act).getNodeParticipantRef("customer") != null;
        }

        /**
         * Determines if a consultation or checkout can be performed on an act.
         *
         * @param act the act
         * @return {@code true} if consultation can be performed
         */
        @Override
        public boolean canCheckoutOrConsult(Act act) {
            String status = act.getStatus();
            return isAppointment(act) && (AppointmentStatus.CHECKED_IN.equals(status)
                    || AppointmentStatus.IN_PROGRESS.equals(status) || AppointmentStatus.COMPLETED.equals(status)
                    || AppointmentStatus.BILLED.equals(status));
        }

        /**
         * Determines if a customer can receive SMS reminder messages for an appointment.
         *
         * @param act the appointment
         * @return {@code true} if the appointment is PENDING, starts today or in the future, and the customer can
         * receive SMS messages
         */
        public boolean canSMS(Act act) {
            boolean result = false;
            ActBean bean = new ActBean(act);
            if (isAppointment(act) && AppointmentStatus.PENDING.equals(act.getStatus())
                    && DateRules.compareDateToToday(act.getActivityStartTime()) >= 0) {
                Party customer = (Party) bean.getNodeParticipant("customer");
                if (customer != null && SMSHelper.canSMS(customer)) {
                    result = true;
                }
            }
            return result;
        }

        /**
         * Determines if a patient can't be transferred to a work list.
         *
         * @param act the act
         * @return {@code true} if the patient can be transferred
         */
        public boolean canTransfer(Act act) {
            String status = act.getStatus();
            return isAppointment(act) && (AppointmentStatus.CHECKED_IN.equals(status)
                    || AppointmentStatus.IN_PROGRESS.equals(status) || AppointmentStatus.ADMITTED.equals(status)
                    || AppointmentStatus.BILLED.equals(status) || AppointmentStatus.COMPLETED.equals(status));
        }
    }

}