diff --git a/example.js b/example.js index 9c42dafe0..e3bb59688 100644 --- a/example.js +++ b/example.js @@ -20,6 +20,7 @@ import { CallTakerWindows, DefaultItinerary, DefaultMainPanel, + FieldTripWindows, MobileSearchScreen, ResponsiveWebapp, createCallTakerReducer, @@ -89,7 +90,12 @@ const components = { : isBatchRoutingEnabled ? BatchRoutingPanel : DefaultMainPanel, - MapWindows: isCallTakerModuleEnabled ? CallTakerWindows : null, + MapWindows: isCallTakerModuleEnabled + ? () => <> + + + + : null, MobileSearchScreen: isBatchRoutingEnabled ? BatchSearchScreen : MobileSearchScreen, diff --git a/lib/actions/call-taker.js b/lib/actions/call-taker.js index 25ccb2ae7..5a48b8284 100644 --- a/lib/actions/call-taker.js +++ b/lib/actions/call-taker.js @@ -1,8 +1,9 @@ import { getUrlParams } from '@opentripplanner/core-utils/lib/query' +import { serialize } from 'object-to-formdata' import qs from 'qs' import { createAction } from 'redux-actions' -import {searchToQuery} from '../util/call-taker' +import {searchToQuery, sessionIsInvalid} from '../util/call-taker' import {URL_ROOT} from '../util/constants' import {getTimestamp} from '../util/state' @@ -29,12 +30,16 @@ export function endCall () { return function (dispatch, getState) { const {callTaker, otp} = getState() const {activeCall, session} = callTaker + const { sessionId } = session if (sessionIsInvalid(session)) return - // Make POST request to store call. - const callData = new FormData() - callData.append('sessionId', session.sessionId) - callData.append('call.startTime', activeCall.startTime) - callData.append('call.endTime', getTimestamp()) + // Make POST request to store new call. + const callData = serialize({ + sessionId, + call: { + startTime: activeCall.startTime, + endTime: getTimestamp() + } + }) fetch(`${otp.config.datastoreUrl}/calltaker/call`, {method: 'POST', body: callData} ) @@ -84,9 +89,8 @@ function newSession (datastoreUrl, verifyLoginUrl, redirect) { .then(res => res.json()) .then(data => { const {sessionId: session} = data - console.log('newSession success: ' + session) const windowUrl = `${verifyLoginUrl}?${qs.stringify({session, redirect})}` - console.log('redirecting to: ' + windowUrl) + // Redirect to login url. window.location = windowUrl }) .catch(error => { @@ -104,8 +108,8 @@ function checkSession (datastoreUrl, sessionId) { .then(res => res.json()) .then(session => dispatch(storeSession({session}))) .catch(error => { - console.error('checkSession error', error) dispatch(storeSession({session: null})) + alert(`Error establishing auth session: ${JSON.stringify(error)}`) }) } } @@ -120,28 +124,12 @@ export function fetchCalls () { if (sessionIsInvalid(callTaker.session)) return const {datastoreUrl} = otp.config const {sessionId} = callTaker.session - const limit = 10 + const limit = 30 fetch(`${datastoreUrl}/calltaker/call?${qs.stringify({limit, sessionId})}`) .then(res => res.json()) - .then(calls => { - console.log('GET calls response', calls) - dispatch(receivedCalls({calls})) - }) - .catch(err => { - alert(`Could not fetch calls: ${JSON.stringify(err)}`) - }) - } -} - -/** - * @return {boolean} - whether a calltaker session is invalid - */ -function sessionIsInvalid (session) { - if (!session || !session.sessionId) { - console.error('No valid OTP datastore session found.') - return true + .then(calls => dispatch(receivedCalls({calls}))) + .catch(err => alert(`Error fetching calls: ${JSON.stringify(err)}`)) } - return false } /** @@ -161,19 +149,13 @@ export function saveQueriesForCall (call) { const search = otp.searches[searchId] const query = searchToQuery(search, call, otp.config) const {sessionId} = callTaker.session - const queryData = new FormData() - queryData.append('sessionId', sessionId) - queryData.append('query.queryParams', query.queryParams) - queryData.append('query.fromPlace', query.fromPlace) - queryData.append('query.toPlace', query.toPlace) - queryData.append('query.timeStamp', query.timeStamp) - queryData.append('query.call.id', call.id) + const queryData = serialize({ sessionId, query }) return fetch(`${datastoreUrl}/calltaker/callQuery?sessionId=${sessionId}`, {method: 'POST', body: queryData} ) .then(res => res.json()) .catch(err => { - alert(`Could not fetch calls: ${JSON.stringify(err)}`) + alert(`Error storing call queries: ${JSON.stringify(err)}`) }) })) } @@ -195,7 +177,7 @@ export function fetchQueries (callId) { dispatch(receivedQueries({callId, queries})) }) .catch(err => { - alert(`Could not fetch calls: ${JSON.stringify(err)}`) + alert(`Error fetching queries: ${JSON.stringify(err)}`) }) } } diff --git a/lib/actions/field-trip.js b/lib/actions/field-trip.js new file mode 100644 index 000000000..69d072347 --- /dev/null +++ b/lib/actions/field-trip.js @@ -0,0 +1,322 @@ +import { planParamsToQueryAsync } from '@opentripplanner/core-utils/lib/query' +import { OTP_API_DATE_FORMAT, OTP_API_TIME_FORMAT } from '@opentripplanner/core-utils/lib/time' +import moment from 'moment' +import { serialize } from 'object-to-formdata' +import qs from 'qs' +import { createAction } from 'redux-actions' + +import {routingQuery} from './api' +import {setQueryParam} from './form' +import {getGroupSize, getTripFromRequest, sessionIsInvalid} from '../util/call-taker' + +if (typeof (fetch) === 'undefined') require('isomorphic-fetch') + +/// PRIVATE ACTIONS + +const receivedFieldTrips = createAction('RECEIVED_FIELD_TRIPS') +const requestingFieldTrips = createAction('REQUESTING_FIELD_TRIPS') +const receivedFieldTripDetails = createAction('RECEIVED_FIELD_TRIP_DETAILS') +const requestingFieldTripDetails = createAction('REQUESTING_FIELD_TRIP_DETAILS') + +// PUBLIC ACTIONS + +export const setFieldTripFilter = createAction('SET_FIELD_TRIP_FILTER') +export const setActiveFieldTrip = createAction('SET_ACTIVE_FIELD_TRIP') +export const setGroupSize = createAction('SET_GROUP_SIZE') +export const toggleFieldTrips = createAction('TOGGLE_FIELD_TRIPS') + +/** + * Fetch all field trip requests (as summaries). + */ +export function fetchFieldTrips () { + return async function (dispatch, getState) { + dispatch(requestingFieldTrips()) + const {callTaker, otp} = getState() + if (sessionIsInvalid(callTaker.session)) return + const {datastoreUrl} = otp.config + const {sessionId} = callTaker.session + let fieldTrips = [] + try { + const res = await fetch(`${datastoreUrl}/fieldtrip/getRequestsSummary?${qs.stringify({sessionId})}`) + fieldTrips = await res.json() + } catch (e) { + alert(`Error fetching field trips: ${JSON.stringify(e)}`) + } + dispatch(receivedFieldTrips({fieldTrips})) + } +} + +/** + * Fetch details for a particular field trip request. + */ +export function fetchFieldTripDetails (requestId) { + return function (dispatch, getState) { + dispatch(requestingFieldTripDetails()) + const {callTaker, otp} = getState() + if (sessionIsInvalid(callTaker.session)) return + const {datastoreUrl} = otp.config + const {sessionId} = callTaker.session + fetch(`${datastoreUrl}/fieldtrip/getRequest?${qs.stringify({requestId, sessionId})}`) + .then(res => res.json()) + .then(fieldTrip => dispatch(receivedFieldTripDetails({fieldTrip}))) + .catch(err => { + alert(`Error fetching field trips: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Add note for field trip request. + */ +export function addFieldTripNote (request, note) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId, username} = callTaker.session + const noteData = serialize({ + sessionId, + note: {...note, userName: username}, + requestId: request.id + }) + return fetch(`${datastoreUrl}/fieldtrip/addNote`, + {method: 'POST', body: noteData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error adding field trip note: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Delete a specific note for a field trip request. + */ +export function deleteFieldTripNote (request, noteId) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + return fetch(`${datastoreUrl}/fieldtrip/deleteNote`, + {method: 'POST', body: serialize({ noteId, sessionId })} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error deleting field trip note: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Edit teacher (AKA submitter) notes for a field trip request. + */ +export function editSubmitterNotes (request, submitterNotes) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const noteData = serialize({ + notes: submitterNotes, + requestId: request.id, + sessionId + }) + return fetch(`${datastoreUrl}/fieldtrip/editSubmitterNotes`, + {method: 'POST', body: noteData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error editing submitter notes: ${JSON.stringify(err)}`) + }) + } +} + +export function saveRequestTrip (request, outbound, groupPlan) { + return function (dispatch, getState) { + // If plan is not valid, return before persisting trip. + const check = checkPlanValidity(request, groupPlan) + if (!check.isValid) return alert(check.message) + const requestOrder = outbound ? 0 : 1 + const type = outbound ? 'outbound' : 'inbound' + const preExistingTrip = getTripFromRequest(request, outbound) + if (preExistingTrip) { + const msg = `This action will overwrite a previously planned ${type} itinerary for this request. Do you wish to continue?` + if (!confirm(msg)) return + } + alert(`TODO: Save trip in request order ${requestOrder}!`) + // TODO: Enable saveTrip + // dispatch(saveTrip(request, requestOrder)) + } +} + +/** + * @typedef {Object} ValidationCheck + * @property {boolean} isValid - Whether the check is valid + * @property {string} message - The message explaining why the check returned + * invalid. + */ + +/** + * Checks that a group plan is valid for a given request, i.e., that it occurs + * on the requested travel date. + * @param request field trip request + * @param groupPlan the group plan to check + * @return {ValidationCheck} + */ +function checkPlanValidity (request, groupPlan) { + if (groupPlan == null) { + return { + isValid: false, + message: 'No active plan to save' + } + } + + // FIXME: add back in offset? + const planDeparture = moment(groupPlan.earliestStartTime) // .add('hours', otp.config.timeOffset) + const requestDate = moment(request.travelDate) + + if ( + planDeparture.date() !== requestDate.date() || + planDeparture.month() !== requestDate.month() || + planDeparture.year() !== requestDate.year() + ) { + return { + isValid: false, + message: `Planned trip date (${planDeparture.format('MM/DD/YYYY')}) is not the requested day of travel (${requestDate.format('MM/DD/YYYY')})` + } + } + + // FIXME More checks? E.g., origin/destination + + return { isValid: true, message: null } +} + +export function planTrip (request, outbound) { + return async function (dispatch, getState) { + dispatch(setGroupSize(getGroupSize(request))) + const trip = getTripFromRequest(request, outbound) + if (!trip) { + // Construct params from request details + if (outbound) dispatch(planOutbound(request)) + else dispatch(planInbound(request)) + } else { + // Populate params from saved query params + const params = await planParamsToQueryAsync(JSON.parse(trip.queryParams)) + dispatch(setQueryParam(params, trip.id)) + } + } +} + +function planOutbound (request) { + return async function (dispatch, getState) { + const {config} = getState().otp + // clearTrip() + const locations = await planParamsToQueryAsync({ + fromPlace: request.startLocation, + toPlace: request.endLocation + }, config) + const queryParams = { + date: moment(request.travelDate).format(OTP_API_DATE_FORMAT), + departArrive: 'ARRIVE', + groupSize: getGroupSize(request), + time: moment(request.arriveDestinationTime).format(OTP_API_TIME_FORMAT), + ...locations + } + dispatch(setQueryParam(queryParams)) + dispatch(routingQuery()) + } +} + +function planInbound (request) { + return async function (dispatch, getState) { + const {config} = getState().otp + const locations = await planParamsToQueryAsync({ + fromPlace: request.endLocation, + toPlace: request.startLocation + }, config) + // clearTrip() + const queryParams = { + date: moment(request.travelDate).format(OTP_API_DATE_FORMAT), + departArrive: 'DEPART', + groupSize: getGroupSize(request), + time: moment(request.leaveDestinationTime).format(OTP_API_TIME_FORMAT), + ...locations + } + dispatch(setQueryParam(queryParams)) + dispatch(routingQuery()) + } +} + +/** + * Set group size for a field trip request. Group size consists of numStudents, + * numFreeStudents, and numChaperones. + */ +export function setRequestGroupSize (request, groupSize) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const groupSizeData = serialize({ + ...groupSize, + requestId: request.id, + sessionId + }) + return fetch(`${datastoreUrl}/fieldtrip/setRequestGroupSize`, + {method: 'POST', body: groupSizeData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error setting group size: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Set payment info for a field trip request. + */ +export function setRequestPaymentInfo (request, paymentInfo) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const paymentData = serialize({ + ...paymentInfo, + requestId: request.id, + sessionId + }) + return fetch(`${datastoreUrl}/fieldtrip/setRequestPaymentInfo`, + {method: 'POST', body: paymentData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error setting payment info: ${JSON.stringify(err)}`) + }) + } +} + +/** + * Set field trip request status (e.g., cancelled). + */ +export function setRequestStatus (request, status) { + return function (dispatch, getState) { + const {callTaker, otp} = getState() + const {datastoreUrl} = otp.config + if (sessionIsInvalid(callTaker.session)) return + const {sessionId} = callTaker.session + const statusData = serialize({ + requestId: request.id, + sessionId, + status + }) + return fetch(`${datastoreUrl}/fieldtrip/setRequestStatus`, + {method: 'POST', body: statusData} + ) + .then(() => dispatch(fetchFieldTripDetails(request.id))) + .catch(err => { + alert(`Error setting request status: ${JSON.stringify(err)}`) + }) + } +} diff --git a/lib/components/admin/call-record.js b/lib/components/admin/call-record.js index 602599109..ff8ae8ab4 100644 --- a/lib/components/admin/call-record.js +++ b/lib/components/admin/call-record.js @@ -38,7 +38,7 @@ export default class CallRecord extends Component { style={{color: RED, fontSize: '8px', verticalAlign: '2px'}} type='circle' className='animate-flicker' /> - + {' '} [Active call] diff --git a/lib/components/admin/call-taker-controls.js b/lib/components/admin/call-taker-controls.js index 56e4b39c6..a5f4ff803 100644 --- a/lib/components/admin/call-taker-controls.js +++ b/lib/components/admin/call-taker-controls.js @@ -1,30 +1,39 @@ import React, { Component } from 'react' import { connect } from 'react-redux' +import * as apiActions from '../../actions/api' import * as callTakerActions from '../../actions/call-taker' -import { routingQuery } from '../../actions/api' -import { setMainPanelContent } from '../../actions/ui' -import CallTimeCounter from './call-time-counter' +import * as fieldTripActions from '../../actions/field-trip' +import * as uiActions from '../../actions/ui' import Icon from '../narrative/icon' - -const RED = '#C35134' -const BLUE = '#1C4D89' -const GREEN = '#6B931B' +import { + CallHistoryButton, + CallTimeCounter, + ControlsContainer, + FieldTripsButton, + ToggleCallButton +} from './styled' /** * This component displays the controls for the Call Taker/Field Trip modules, * including: * - start/end call button * - view call list - * TODO * - view field trip list */ class CallTakerControls extends Component { componentDidUpdate (prevProps) { - const {session} = this.props + const { + callTakerEnabled, + fetchCalls, + fetchFieldTrips, + fieldTripEnabled, + session + } = this.props // Once session is available, fetch calls. if (session && !prevProps.session) { - this.props.fetchCalls() + if (callTakerEnabled) fetchCalls() + if (fieldTripEnabled) fetchFieldTrips() } } @@ -33,7 +42,7 @@ class CallTakerControls extends Component { else this.props.beginCall() } - _renderCallButton = () => { + _renderCallButtonIcon = () => { // Show stop button if call not in progress. if (this._callInProgress()) { return ( @@ -57,74 +66,57 @@ class CallTakerControls extends Component { _onToggleCallHistory = () => this.props.toggleCallHistory() + _onToggleFieldTrips = () => this.props.toggleFieldTrips() + _callInProgress = () => Boolean(this.props.activeCall) render () { - const {session} = this.props + const {callTakerEnabled, fieldTripEnabled, session} = this.props // If no valid session is found, do not show calltaker controls. if (!session) return null - // FIXME: styled component - const circleButtonStyle = { - position: 'absolute', - zIndex: 999999, - color: 'white', - borderRadius: '50%', - border: 'none', - boxShadow: '2px 2px 4px #000000' - } return ( - <> + {/* Start/End Call button */} - + {callTakerEnabled && + + {this._renderCallButtonIcon()} + + } {this._callInProgress() - ? + ? : null } {/* Call History toggle button */} - - {/* Field Trip toggle button TODO */} - + {callTakerEnabled && + + + + } + {/* Field Trip toggle button */} + {fieldTripEnabled && + + + + } + ) } } @@ -132,19 +124,21 @@ class CallTakerControls extends Component { const mapStateToProps = (state, ownProps) => { return { activeCall: state.callTaker.activeCall, + callTakerEnabled: Boolean(state.otp.config.modules.find(m => m.id === 'call')), + fieldTripEnabled: Boolean(state.otp.config.modules.find(m => m.id === 'ft')), session: state.callTaker.session } } -const {beginCall, endCall, fetchCalls, toggleCallHistory} = callTakerActions - const mapDispatchToProps = { - beginCall, - endCall, - fetchCalls, - routingQuery, - setMainPanelContent, - toggleCallHistory + beginCall: callTakerActions.beginCall, + endCall: callTakerActions.endCall, + fetchCalls: callTakerActions.fetchCalls, + fetchFieldTrips: fieldTripActions.fetchFieldTrips, + routingQuery: apiActions.routingQuery, + setMainPanelContent: uiActions.setMainPanelContent, + toggleCallHistory: callTakerActions.toggleCallHistory, + toggleFieldTrips: fieldTripActions.toggleFieldTrips } export default connect(mapStateToProps, mapDispatchToProps)(CallTakerControls) diff --git a/lib/components/admin/call-taker-windows.js b/lib/components/admin/call-taker-windows.js index b575cc2ca..a169c2a09 100644 --- a/lib/components/admin/call-taker-windows.js +++ b/lib/components/admin/call-taker-windows.js @@ -5,6 +5,7 @@ import * as callTakerActions from '../../actions/call-taker' import CallRecord from './call-record' import DraggableWindow from './draggable-window' import Icon from '../narrative/icon' +import {WindowHeader} from './styled' /** * Collects the various draggable windows used in the Call Taker module to @@ -12,40 +13,33 @@ import Icon from '../narrative/icon' */ class CallTakerWindows extends Component { render () { - const {callTaker, fetchQueries, searches} = this.props + const {callTaker, fetchQueries, searches, toggleCallHistory} = this.props const {activeCall, callHistory} = callTaker + if (!callHistory.visible) return null return ( - <> - {callHistory.visible - // Active call window - ? Call history} - onClickClose={this.props.toggleCallHistory} - > - {activeCall - ? - : null - } - {callHistory.calls.data.length > 0 - ? callHistory.calls.data.map((call, i) => ( - - )) - :
No calls in history
- } -
+ Call history} + onClickClose={toggleCallHistory} + style={{right: '15px', top: '50px'}} + > + {activeCall + ? : null } - + {callHistory.calls.data.length > 0 + ? callHistory.calls.data.map((call, i) => ( + + )) + :
No calls in history
+ } +
) } } @@ -58,11 +52,9 @@ const mapStateToProps = (state, ownProps) => { } } -const { - fetchQueries, - toggleCallHistory -} = callTakerActions - -const mapDispatchToProps = { fetchQueries, toggleCallHistory } +const mapDispatchToProps = { + fetchQueries: callTakerActions.fetchQueries, + toggleCallHistory: callTakerActions.toggleCallHistory +} export default connect(mapStateToProps, mapDispatchToProps)(CallTakerWindows) diff --git a/lib/components/admin/call-time-counter.js b/lib/components/admin/call-time-counter.js index 1d8dfe763..1383687a4 100644 --- a/lib/components/admin/call-time-counter.js +++ b/lib/components/admin/call-time-counter.js @@ -43,9 +43,14 @@ export default class CallTimeCounter extends Component { } render () { - const {className, style} = this.props + const {className} = this.props return ( -
+
{this._formatSeconds(this.state.counterString)}
) diff --git a/lib/components/admin/draggable-window.js b/lib/components/admin/draggable-window.js index 1159cdd39..a43789139 100644 --- a/lib/components/admin/draggable-window.js +++ b/lib/components/admin/draggable-window.js @@ -7,30 +7,34 @@ const noop = () => {} export default class DraggableWindow extends Component { render () { - const {children, draggableProps, header} = this.props + const { + children, + draggableProps, + footer, + header, + height = '245px', + onClickClose, + style + } = this.props const GREY_BORDER = '#777 1.3px solid' return (
{header}
{children}
+ {footer && +
+ {footer} +
+ }
) diff --git a/lib/components/admin/editable-section.js b/lib/components/admin/editable-section.js new file mode 100644 index 000000000..e5b8cf27f --- /dev/null +++ b/lib/components/admin/editable-section.js @@ -0,0 +1,147 @@ +import { Field, Form, Formik } from 'formik' +import React, {Component} from 'react' + +import { + Button, + Para, + Val +} from './styled' + +/** + * This component will render a set of fields along with a 'Change/Edit' button + * that toggles an editable state. In its editable state, the fields/values are + * rendered as form inputs, which can be edited and then saved/persisted. + */ +export default class EditableSection extends Component { + state = { + isEditing: false + } + + _exists = (val) => val !== null && typeof val !== 'undefined' + + _getVal = (fieldName) => this._exists(this.props.request[fieldName]) + ? this.props.request[fieldName] + : '' + + _onClickSave = data => { + const {request, onChange} = this.props + // Convert all null values received to '', + // otherwise they will appear in the backend as the 'null' string. + for (const field in data) { + if (data[field] === null) data[field] = '' + } + + onChange(request, data) + this.setState({isEditing: false}) + } + + _toggleEditing = () => { + this.setState({isEditing: !this.state.isEditing}) + } + + render () { + const {children, fields, inputStyle, request, valueFirst} = this.props + const {isEditing} = this.state + if (!request) return null + return ( + + { + formikProps => ( +
+ {children} + + {!isEditing + ? + : <> + + + + } + + {fields.map(f => { + const input = ( + + ) + return ( + + {valueFirst + ? <>{input} {f.label} + : <>{f.label}: {input} + } + + ) + })} + + ) + } +
+ ) + } +} + +/** + * This component renders either the specified value for a given field or, if + * in an active editing state, the associated field's respective input (e.g. + * text, number, or select). + */ +class InputToggle extends Component { + render () { + const {inputProps, fieldName, isEditing, options, style, value} = this.props + if (isEditing) { + if (options) { + return ( + + {Object.keys(options).map(k => + + )} + + ) + } else { + return + } + } + return {options ? options[value] : value} + } +} diff --git a/lib/components/admin/field-trip-details.js b/lib/components/admin/field-trip-details.js new file mode 100644 index 000000000..91d2f2af6 --- /dev/null +++ b/lib/components/admin/field-trip-details.js @@ -0,0 +1,233 @@ +import { getDateFormat } from '@opentripplanner/core-utils/lib/time' +import moment from 'moment' +import React, { Component } from 'react' +import { DropdownButton, MenuItem } from 'react-bootstrap' +import { connect } from 'react-redux' +import styled from 'styled-components' + +import * as fieldTripActions from '../../actions/field-trip' +import DraggableWindow from './draggable-window' +import EditableSection from './editable-section' +import FieldTripNotes from './field-trip-notes' +import Icon from '../narrative/icon' +import { + Bold, + Button, + Container, + FullWithMargin, + Half, + Header, + InlineHeader, + Para, + Text, + Val, + WindowHeader as DefaultWindowHeader +} from './styled' +import TripStatus from './trip-status' +import Updatable from './updatable' +import { + getGroupSize, + GROUP_FIELDS, + PAYMENT_FIELDS, + TICKET_TYPES +} from '../../util/call-taker' + +const WindowHeader = styled(DefaultWindowHeader)` + margin-bottom: 0px; +` + +/** + * Shows the details for the active Field Trip Request. + */ +class FieldTripDetails extends Component { + _editSubmitterNotes = (val) => this.props.editSubmitterNotes(this.props.request, val) + + _getRequestLink = (path, isPublic = false) => + `${this.props.datastoreUrl}/${isPublic ? 'public/' : ''}fieldtrip/${path}?requestId=${this.props.request.id}` + + _onCloseActiveFieldTrip = () => this.props.setActiveFieldTrip(null) + + _onToggleStatus = () => { + const {request, setRequestStatus} = this.props + if (request.status !== 'cancelled') { + if (confirm('Are you sure you want to cancel this request? Any associated trips will be deleted.')) { + setRequestStatus(request, 'cancelled') + } + } else { + setRequestStatus(request, 'active') + } + } + + _renderFooter = () => { + const cancelled = this.props.request.status === 'cancelled' + return ( +
+ + + Feedback link + + + Receipt link + + + +
+ ) + } + + _renderHeader = () => { + const {dateFormat, request} = this.props + const { + id, + schoolName, + travelDate + } = request + const travelDateAsMoment = moment(travelDate) + return ( + + {schoolName} Trip (#{id}) +
+ + Travel date: {travelDateAsMoment.format(dateFormat)}{' '} + ({travelDateAsMoment.fromNow()}) + +
+
+ ) + } + + render () { + const { + addFieldTripNote, + deleteFieldTripNote, + request, + setRequestGroupSize, + setRequestPaymentInfo, + style + } = this.props + if (!request) return null + const { + invoiceRequired, + notes, + schoolName, + submitterNotes, + teacherName, + ticketType + } = request + const internalNotes = [] + const operationalNotes = [] + notes && notes.forEach(note => { + if (note.type === 'internal') internalNotes.push(note) + else operationalNotes.push(note) + }) + return ( + + +
Group Information
+ + {schoolName} + Teacher: {teacherName} + Ticket type: {TICKET_TYPES[ticketType]} + Invoice required: {invoiceRequired ? 'Yes' : 'No'} + + } + onUpdate={this._editSubmitterNotes} + value={submitterNotes} + /> + + + + + {getGroupSize(request)} total + + } + fields={GROUP_FIELDS} + inputStyle={{lineHeight: '0.8em', padding: '0px', width: '50px'}} + onChange={setRequestGroupSize} + request={request} + valueFirst + /> + + + + + + Payment information + + } + fields={PAYMENT_FIELDS} + inputStyle={{lineHeight: '0.8em', padding: '0px', width: '100px'}} + onChange={setRequestPaymentInfo} + request={request} + /> + + +
+
+ ) + } +} + +const mapStateToProps = (state, ownProps) => { + const {activeId, requests} = state.callTaker.fieldTrip + const request = requests.data.find(req => req.id === activeId) + return { + currentQuery: state.otp.currentQuery, + datastoreUrl: state.otp.config.datastoreUrl, + dateFormat: getDateFormat(state.otp.config), + request + } +} + +const mapDispatchToProps = { + addFieldTripNote: fieldTripActions.addFieldTripNote, + deleteFieldTripNote: fieldTripActions.deleteFieldTripNote, + editSubmitterNotes: fieldTripActions.editSubmitterNotes, + fetchQueries: fieldTripActions.fetchQueries, + setActiveFieldTrip: fieldTripActions.setActiveFieldTrip, + setRequestGroupSize: fieldTripActions.setRequestGroupSize, + setRequestPaymentInfo: fieldTripActions.setRequestPaymentInfo, + setRequestStatus: fieldTripActions.setRequestStatus, + toggleFieldTrips: fieldTripActions.toggleFieldTrips +} + +export default connect(mapStateToProps, mapDispatchToProps)(FieldTripDetails) diff --git a/lib/components/admin/field-trip-list.js b/lib/components/admin/field-trip-list.js new file mode 100644 index 000000000..84152aa2d --- /dev/null +++ b/lib/components/admin/field-trip-list.js @@ -0,0 +1,206 @@ +import React, { Component } from 'react' +import { Badge } from 'react-bootstrap' +import { connect } from 'react-redux' + +import * as fieldTripActions from '../../actions/field-trip' +import DraggableWindow from './draggable-window' +import Icon from '../narrative/icon' +import Loading from '../narrative/loading' +import {FieldTripRecordButton, WindowHeader} from './styled' +import {getVisibleRequests, TABS} from '../../util/call-taker' +import {FETCH_STATUS} from '../../util/constants' + +/** + * Displays a searchable list of field trip requests in a draggable window. + */ +class FieldTripList extends Component { + _onClickFieldTrip = (request) => { + const {callTaker, fetchFieldTripDetails, setActiveFieldTrip} = this.props + if (request.id === callTaker.fieldTrip.activeId) { + this._onCloseActiveFieldTrip() + } else { + setActiveFieldTrip(request.id) + fetchFieldTripDetails(request.id) + } + } + + _onClickRefresh = () => this.props.fetchFieldTrips() + + _onCloseActiveFieldTrip = () => { + this.props.setActiveFieldTrip(null) + } + + /** + * Change search input selectively. This is to prevent excessive rendering + * each time the search input changes (on TriMet's production instance there + * are thousands of field trip requests). + */ + _handleSearchKeyUp = e => { + const {callTaker, setFieldTripFilter} = this.props + const {search} = callTaker.fieldTrip.filter + const newSearch = e.target.value + // Update filter if Enter is pressed or search value is entirely cleared. + const newSearchEntered = e.keyCode === 13 && newSearch !== search + const searchCleared = search && !newSearch + if (newSearchEntered || searchCleared) { + setFieldTripFilter({search: newSearch}) + } + } + + _onTabChange = e => { + this.props.setFieldTripFilter({tab: e.currentTarget.name}) + } + + render () { + const {callTaker, style, toggleFieldTrips, visibleRequests} = this.props + const {fieldTrip} = callTaker + const {activeId, filter} = fieldTrip + const {search} = filter + return ( + + + Field Trip Requests{' '} + + + + + + {TABS.map(tab => { + const active = tab.id === filter.tab + const style = { + backgroundColor: active ? 'navy' : undefined, + borderRadius: 5, + color: active ? 'white' : undefined, + padding: '2px 3px' + } + const requestCount = fieldTrip.requests.data.filter(tab.filter).length + return ( + + ) + })} + + } + onClickClose={toggleFieldTrips} + style={style} + > + {fieldTrip.requests.status === FETCH_STATUS.FETCHING + ? + : visibleRequests.length > 0 + ? visibleRequests.map((request, i) => ( + + )) + :
No field trips found.
+ } +
+ ) + } +} + +class FieldTripRequestRecord extends Component { + _onClick = () => { + const {onClick, request} = this.props + onClick(request) + } + + _getStatusIcon = (status) => status + ? + : + + render () { + const {active, request} = this.props + const style = { + backgroundColor: active ? 'lightgrey' : undefined, + borderBottom: '1px solid grey' + } + const { + endLocation, + id, + inboundTripStatus, + outboundTripStatus, + schoolName, + startLocation, + teacherName, + timeStamp + } = request + return ( +
  • + + + {schoolName} Trip (#{id}) + + + + {this._getStatusIcon(inboundTripStatus)} Inbound + + + {this._getStatusIcon(outboundTripStatus)} Outbound + + + + Submitted by {teacherName} on {timeStamp} + + + {startLocation} to {endLocation} + + +
  • + ) + } +} + +const mapStateToProps = (state, ownProps) => { + return { + callTaker: state.callTaker, + currentQuery: state.otp.currentQuery, + searches: state.otp.searches, + visibleRequests: getVisibleRequests(state) + } +} + +const mapDispatchToProps = { + fetchFieldTripDetails: fieldTripActions.fetchFieldTripDetails, + fetchFieldTrips: fieldTripActions.fetchFieldTrips, + setActiveFieldTrip: fieldTripActions.setActiveFieldTrip, + setFieldTripFilter: fieldTripActions.setFieldTripFilter, + toggleFieldTrips: fieldTripActions.toggleFieldTrips +} + +export default connect(mapStateToProps, mapDispatchToProps)(FieldTripList) diff --git a/lib/components/admin/field-trip-notes.js b/lib/components/admin/field-trip-notes.js new file mode 100644 index 000000000..a190f5f5d --- /dev/null +++ b/lib/components/admin/field-trip-notes.js @@ -0,0 +1,116 @@ +import React, { Component } from 'react' +import { Badge, Button as BsButton } from 'react-bootstrap' +import styled from 'styled-components' + +import Icon from '../narrative/icon' +import { + Button, + Full, + Header +} from './styled' + +const Quote = styled.p` + font-size: small; + margin-bottom: 5px; +` + +const Footer = styled.footer` + font-size: x-small; +` + +const Note = ({note, onClickDelete}) => ( +
    + onClickDelete(note)} + > + + + {note.note} +
    {note.userName} on {note.timeStamp}
    +
    +) + +const Feedback = ({feedback}) => ( +
    + {feedback.feedback} +
    {feedback.userName} on {feedback.timeStamp}
    +
    +) +/** + * Renders the various notes/feedback for a field trip request. + */ +export default class FieldTripNotes extends Component { + _getNotesCount = () => { + const {request} = this.props + const {feedback, notes} = request + let notesCount = 0 + if (notes && notes.length) notesCount += notes.length + if (feedback && feedback.length) notesCount += feedback.length + return notesCount + } + + _addInternalNote = () => this._addNote('internal') + + _addOperationalNote = () => this._addNote('operational') + + _addNote = (type) => { + const {addFieldTripNote, request} = this.props + const note = prompt(`Type ${type} note to be attached to this request:`) + if (note) addFieldTripNote(request, {note, type}) + } + + _deleteNote = (note) => { + const {deleteFieldTripNote, request} = this.props + if (confirm(`Are you sure you want to delete note "${note.note}"?`)) { + deleteFieldTripNote(request, note.id) + } + } + + render () { + const {request} = this.props + if (!request) return null + const { + feedback, + notes + } = request + const internalNotes = [] + const operationalNotes = [] + notes && notes.forEach(note => { + if (note.type === 'internal') internalNotes.push(note) + else operationalNotes.push(note) + }) + return ( + +
    + Notes/Feedback{' '} + {this._getNotesCount()} + + +
    +
    Internal agent notes
    + {internalNotes && internalNotes.length > 0 + ? internalNotes.map(n => + ) + : 'No internal notes submitted.' + } +
    Operational notes
    + {operationalNotes && operationalNotes.length > 0 + ? operationalNotes.map(n => + ) + : 'No operational notes submitted.' + } +
    User feedback
    + {feedback && feedback.length > 0 + ? feedback.map((f, i) => ) + : 'No feedback submitted.' + } +
    + ) + } +} diff --git a/lib/components/admin/field-trip-windows.js b/lib/components/admin/field-trip-windows.js new file mode 100644 index 000000000..ddb6ee7ca --- /dev/null +++ b/lib/components/admin/field-trip-windows.js @@ -0,0 +1,46 @@ +import React from 'react' +import { connect } from 'react-redux' + +import FieldTripDetails from './field-trip-details' +import FieldTripList from './field-trip-list' + +const WINDOW_WIDTH = 450 +const MARGIN = 15 + +/** + * Collects the various draggable windows for the Field Trip module. + */ +const FieldTripWindows = ({callTaker}) => { + const {fieldTrip} = callTaker + // Do not render details or list if visible is false. + if (!fieldTrip.visible) return null + return ( + <> + + + + ) +} + +const mapStateToProps = (state, ownProps) => { + return { + callTaker: state.callTaker + } +} + +export default connect(mapStateToProps)(FieldTripWindows) diff --git a/lib/components/admin/styled.js b/lib/components/admin/styled.js new file mode 100644 index 000000000..c5f53259d --- /dev/null +++ b/lib/components/admin/styled.js @@ -0,0 +1,134 @@ +import { Button as BsButton } from 'react-bootstrap' +import styled, {css} from 'styled-components' + +import DefaultCounter from './call-time-counter' + +// Call Taker Controls Components + +const RED = '#C35134' +const BLUE = '#1C4D89' +const GREEN = '#6B931B' +const PURPLE = '#8134D3' + +const circleButtonStyle = css` + border: none; + border-radius: 50%; + box-shadow: 2px 2px 4px #000000; + color: white; + position: absolute; + z-index: 999999; +` + +export const CallHistoryButton = styled.button` + ${circleButtonStyle} + background-color: ${GREEN}; + height: 40px; + margin-left: 69px; + top: 140px; + width: 40px; +` + +export const CallTimeCounter = styled(DefaultCounter)` + background-color: ${BLUE}; + border-radius: 20px; + box-shadow: 2px 2px 4px #000000; + color: white; + font-weight: 600; + margin-left: -8px; + position: absolute; + text-align: center; + top: 241px; + width: 80px; + z-index: 999999; +` + +export const ControlsContainer = styled.div` + position: relative; +` + +export const FieldTripsButton = styled.button` + ${circleButtonStyle} + background-color: ${PURPLE}; + height: 50px; + margin-left: 80px; + top: 190px; + width: 50px; +` + +export const ToggleCallButton = styled.button` + ${circleButtonStyle} + background-color: ${props => props.callInProgress ? RED : BLUE}; + height: 80px; + margin-left: -8px; + top: 154px; + width: 80px; +` + +// Field Trip Windows Components + +export const Bold = styled.strong`` + +export const Button = styled(BsButton)` + margin-left: 5px; +` + +export const Container = styled.div` + display: flex; + flex-flow: row wrap; +` + +export const Half = styled.div` + width: 50% +` + +// Make sure button extends to end of window. +export const FieldTripRecordButton = styled.button` + display: inline-block; + width: 100%; + width: -moz-available; + width: -webkit-fill-available; + width: fill-available; +` + +export const Full = styled.div` + width: 100% +` + +export const FullWithMargin = styled(Full)` + margin-top: 10px; +` + +export const Header = styled.h4` + margin-bottom: 5px; + width: 100%; +` + +export const InlineHeader = styled(Header)` + display: inline; +` + +export const textCss = css` + font-size: 0.9em; + margin-bottom: 0px; +` + +export const Para = styled.p` + ${textCss} +` + +export const Text = styled.span` + ${textCss} +` + +export const Val = styled.span` + :empty:before { + color: grey; + content: 'N/A'; + } +` + +export const WindowHeader = styled.h3` + font-size: 18px; + margin-bottom: 10px; + margin-top: 10px; +` diff --git a/lib/components/admin/trip-status.js b/lib/components/admin/trip-status.js new file mode 100644 index 000000000..741b12912 --- /dev/null +++ b/lib/components/admin/trip-status.js @@ -0,0 +1,109 @@ +import { getTimeFormat } from '@opentripplanner/core-utils/lib/time' +import moment from 'moment' +import React, {Component} from 'react' +import { connect } from 'react-redux' + +import * as fieldTripActions from '../../actions/field-trip' +import * as formActions from '../../actions/form' +import Icon from '../narrative/icon' +import { + Bold, + Button, + Full, + Header, + Para +} from './styled' +import { getTripFromRequest } from '../../util/call-taker' + +class TripStatus extends Component { + _getTrip = () => getTripFromRequest(this.props.request, this.props.outbound) + + _formatTime = (time) => moment(time).format(this.props.timeFormat) + + _formatTripStatus = () => { + if (!this._getStatus()) { + return ( + + No itineraries planned! Click Plan to plan trip. + + ) + } + const trip = this._getTrip() + if (!trip) return Error finding trip! + return ( + + {trip.groupItineraries.length} group itineraries, planned by{' '} + {trip.createdBy} at {trip.timeStamp} + + ) + } + + _getStatus = () => { + const {outbound, request} = this.props + return outbound ? request.outboundTripStatus : request.inboundTripStatus + } + + _getStatusIcon = () => this._getStatus() + ? + : + + _onPlanTrip = () => this.props.planTrip(this.props.request, this.props.outbound) + + _onSaveTrip = () => this.props.saveRequestTrip(this.props.request, this.props.outbound) + + render () { + const {outbound, request} = this.props + const { + arriveDestinationTime, + arriveSchoolTime, + endLocation, + leaveDestinationTime, + startLocation + } = request + if (!request) { + console.warn('Could not find field trip request') + return null + } + const start = outbound ? startLocation : endLocation + const end = outbound ? endLocation : startLocation + return ( + +
    + {this._getStatusIcon()} + {outbound ? 'Outbound' : 'Inbound'} trip + + +
    + From {start} to {end} + {outbound + ? + Arriving at {this._formatTime(arriveDestinationTime)} + + : <> + + Leave at {this._formatTime(leaveDestinationTime)},{' '} + due back at {this._formatTime(arriveSchoolTime)} + + + } + {this._formatTripStatus()} +
    + ) + } +} + +const mapStateToProps = (state, ownProps) => { + return { + callTaker: state.callTaker, + currentQuery: state.otp.currentQuery, + timeFormat: getTimeFormat(state.otp.config) + } +} + +const mapDispatchToProps = { + planTrip: fieldTripActions.planTrip, + saveRequestTrip: fieldTripActions.saveRequestTrip, + setQueryParam: formActions.setQueryParam +} + +export default connect(mapStateToProps, mapDispatchToProps)(TripStatus) diff --git a/lib/components/admin/updatable.js b/lib/components/admin/updatable.js new file mode 100644 index 000000000..16bd37246 --- /dev/null +++ b/lib/components/admin/updatable.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' +import { Button } from 'react-bootstrap' + +import { Val } from './styled' + +export default class Updatable extends Component { + _onClick = () => { + const {fieldName, onUpdate, value} = this.props + const newValue = window.prompt( + `Please input new value for ${fieldName}:`, + value + ) + if (newValue !== null) onUpdate(newValue) + } + + render () { + const {fieldName, label, value} = this.props + return ( + <> + {label || fieldName}:{' '} + {value} + + + ) + } +} diff --git a/lib/components/app/app-menu.js b/lib/components/app/app-menu.js index 6bf4d9374..703c0752d 100644 --- a/lib/components/app/app-menu.js +++ b/lib/components/app/app-menu.js @@ -1,7 +1,9 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' +import qs from 'qs' import { connect } from 'react-redux' import { DropdownButton, MenuItem } from 'react-bootstrap' +import { withRouter } from 'react-router' import Icon from '../narrative/icon' @@ -19,11 +21,21 @@ class AppMenu extends Component { } _startOver = () => { - const { reactRouterConfig } = this.props + const { location, reactRouterConfig } = this.props + const { search } = location let startOverUrl = '/' if (reactRouterConfig && reactRouterConfig.basename) { startOverUrl += reactRouterConfig.basename } + // If search contains sessionId, preserve this so that the current session + // is not lost when the page reloads. + if (search) { + const params = qs.parse(search, { ignoreQueryPrefix: true }) + const { sessionId } = params + if (sessionId) { + startOverUrl += `?${qs.stringify({sessionId})}` + } + } window.location.href = startOverUrl } @@ -62,4 +74,4 @@ const mapDispatchToProps = { setMainPanelContent } -export default connect(mapStateToProps, mapDispatchToProps)(AppMenu) +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(AppMenu)) diff --git a/lib/components/app/call-taker-panel.js b/lib/components/app/call-taker-panel.js index 055b2a8d0..a15366b49 100644 --- a/lib/components/app/call-taker-panel.js +++ b/lib/components/app/call-taker-panel.js @@ -5,6 +5,7 @@ import { Button } from 'react-bootstrap' import { connect } from 'react-redux' import * as apiActions from '../../actions/api' +import * as fieldTripActions from '../../actions/field-trip' import * as formActions from '../../actions/form' import AddPlaceButton from '../form/add-place-button' import AdvancedOptions from '../form/call-taker/advanced-options' @@ -15,6 +16,7 @@ import LocationField from '../form/connected-location-field' import SwitchButton from '../form/switch-button' import UserSettings from '../form/user-settings' import NarrativeItineraries from '../narrative/narrative-itineraries' +import { getGroupSize } from '../../util/call-taker' import { hasValidLocation, getActiveSearch, getShowUserSettings } from '../../util/state' import ViewerContainer from '../viewers/viewer-container' @@ -42,6 +44,7 @@ class CallTakerPanel extends Component { window.alert(`Please define the following fields to plan a trip: ${issues.join(', ')}`) return } + if (this.state.expandAdvanced) this.setState({expandAdvanced: false}) routingQuery() } @@ -91,11 +94,15 @@ class CallTakerPanel extends Component { } } + _updateGroupSize = (evt) => this.props.setGroupSize(+evt.target.value) + render () { const { activeSearch, currentQuery, + groupSize, mainPanelContent, + maxGroupSize, mobile, modes, routes, @@ -124,7 +131,7 @@ class CallTakerPanel extends Component { right: '0px', left: '0px', padding: '0px 8px 5px', - display: expandAdvanced ? 'none' : undefined + display: expandAdvanced ? undefined : 'none' } return ( @@ -206,6 +213,21 @@ class CallTakerPanel extends Component {
    + {groupSize !== null && maxGroupSize && + + Group size:{' '} + + + }