Added length of appointment to config + fixed appointments not showing in scheduling modal + added appointment confirmation template. BOD-141 BOD-149 BOD-148

This commit is contained in:
Patrick Fic
2020-06-03 16:17:39 -07:00
parent 47f858920b
commit e606401e76
29 changed files with 652 additions and 162 deletions

View File

@@ -280,6 +280,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>smartscheduling</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>viewjob</name> <name>viewjob</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -353,6 +374,27 @@
<folder_node> <folder_node>
<name>fields</name> <name>fields</name>
<children> <children>
<concept_node>
<name>time</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>title</name> <name>title</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -421,6 +463,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>history</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>nodateselected</name> <name>nodateselected</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>
@@ -875,6 +938,27 @@
</translation> </translation>
</translations> </translations>
</concept_node> </concept_node>
<concept_node>
<name>appt_length</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node> <concept_node>
<name>city</name> <name>city</name>
<definition_loaded>false</definition_loaded> <definition_loaded>false</definition_loaded>

View File

@@ -8,20 +8,21 @@ export default function EmailOverlayComponent({
}) { }) {
return ( return (
<div> <div>
To:
<Input <Input
defaultValue={messageOptions.to} value={messageOptions.to}
onChange={handleConfigChange} onChange={handleConfigChange}
name="to" name="to"
/> />
CC CC:
<Input <Input
defaultValue={messageOptions.cc} value={messageOptions.cc}
onChange={handleConfigChange} onChange={handleConfigChange}
name="cc" name="cc"
/> />
Subject Subject:
<Input <Input
defaultValue={messageOptions.subject} value={messageOptions.subject}
onChange={handleConfigChange} onChange={handleConfigChange}
name="subject" name="subject"
/> />

View File

@@ -33,6 +33,7 @@ export function EmailOverlayContainer({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const defaultEmailFrom = { const defaultEmailFrom = {
from: { from: {
name: bodyshop.shopname || EmailSettings.fromNameDefault, name: bodyshop.shopname || EmailSettings.fromNameDefault,
@@ -45,21 +46,19 @@ export function EmailOverlayContainer({
html: "", html: "",
}); });
const handleOk = () => { const handleOk = async () => {
//sendEmail(messageOptions); setSending(true);
axios try {
.post("/sendemail", messageOptions) const emailResponse = await axios.post("/sendemail", messageOptions);
.then((response) => { notification["success"]({ message: t("emails.successes.sent") });
console.log(JSON.stringify(response)); toggleEmailOverlayVisible();
notification["success"]({ message: t("emails.successes.sent") }); } catch (error) {
toggleEmailOverlayVisible(); console.log(JSON.stringify(error));
}) notification["error"]({
.catch((error) => { message: t("emails.errors.notsent", { message: error.message }),
console.log(JSON.stringify(error));
notification["error"]({
message: t("emails.errors.notsent", { message: error.message }),
});
}); });
}
setSending(false);
}; };
const handleConfigChange = (event) => { const handleConfigChange = (event) => {
@@ -72,6 +71,7 @@ export function EmailOverlayContainer({
const render = async () => { const render = async () => {
setLoading(true); setLoading(true);
console.log("emailConfig", emailConfig);
let html = await RenderTemplate(emailConfig.template, bodyshop); let html = await RenderTemplate(emailConfig.template, bodyshop);
setMessageOptions({ setMessageOptions({
...emailConfig.messageOptions, ...emailConfig.messageOptions,
@@ -93,7 +93,9 @@ export function EmailOverlayContainer({
onOk={handleOk} onOk={handleOk}
onCancel={() => { onCancel={() => {
toggleEmailOverlayVisible(); toggleEmailOverlayVisible();
}}> }}
okButtonProps={{ loading: sending }}
>
<LoadingSpinner loading={loading}> <LoadingSpinner loading={loading}>
<EmailOverlayComponent <EmailOverlayComponent
handleConfigChange={handleConfigChange} handleConfigChange={handleConfigChange}
@@ -102,10 +104,10 @@ export function EmailOverlayContainer({
/> />
<button <button
onClick={() => { onClick={() => {
console.log(messageOptions.html);
navigator.clipboard.writeText(messageOptions.html); navigator.clipboard.writeText(messageOptions.html);
}}> }}
Get HTML >
Copy HTML
</button> </button>
</LoadingSpinner> </LoadingSpinner>
</Modal> </Modal>

View File

@@ -1,13 +1,14 @@
import { Input } from "antd"; import { Input } from "antd";
import { MailFilled } from "@ant-design/icons"; import { MailFilled } from "@ant-design/icons";
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import { Link } from "react-router-dom";
function FormItemEmail(props, ref) { function FormItemEmail(props, ref) {
return ( return (
<Input <Input
{...props} {...props}
addonAfter={ addonAfter={
props.email ? ( props.defaultValue ? (
<a href={`mailto:${props.email}`}> <a href={`mailto:${props.defaultValue}`}>
<MailFilled /> <MailFilled />
</a> </a>
) : ( ) : (

View File

@@ -46,7 +46,7 @@ export function JobsDetailHeader({
const tombstoneTitle = ( const tombstoneTitle = (
<div> <div>
<Avatar size='large' alt='Vehicle Image' src={CarImage} /> <Avatar size="large" alt="Vehicle Image" src={CarImage} />
{job.ro_number {job.ro_number
? `${t("jobs.fields.ro_number")} ${job.ro_number}` ? `${t("jobs.fields.ro_number")} ${job.ro_number}`
: `EST-${job.est_number}`} : `EST-${job.est_number}`}
@@ -57,7 +57,8 @@ export function JobsDetailHeader({
<Menu <Menu
onClick={(e) => { onClick={(e) => {
updateJobStatus(e.key); updateJobStatus(e.key);
}}> }}
>
{bodyshop.md_ro_statuses.statuses.map((item) => ( {bodyshop.md_ro_statuses.statuses.map((item) => (
<Menu.Item key={item}>{item}</Menu.Item> <Menu.Item key={item}>{item}</Menu.Item>
))} ))}
@@ -65,12 +66,12 @@ export function JobsDetailHeader({
); );
const menuExtra = [ const menuExtra = [
<Dropdown overlay={statusmenu} key='changestatus'> <Dropdown overlay={statusmenu} key="changestatus">
<Button> <Button>
{t("jobs.actions.changestatus")} <DownCircleFilled /> {t("jobs.actions.changestatus")} <DownCircleFilled />
</Button> </Button>
</Dropdown>, </Dropdown>,
<Badge key='schedule' count={job.appointments_aggregate.aggregate.count}> <Badge key="schedule" count={job.appointments_aggregate.aggregate.count}>
<Button <Button
//TODO Enabled logic based on status. //TODO Enabled logic based on status.
onClick={() => { onClick={() => {
@@ -78,15 +79,17 @@ export function JobsDetailHeader({
actions: { refetch: refetch }, actions: { refetch: refetch },
context: { context: {
jobId: job.id, jobId: job.id,
job: job,
}, },
}); });
}}> }}
>
{t("jobs.actions.schedule")} {t("jobs.actions.schedule")}
</Button> </Button>
</Badge>, </Badge>,
<Button <Button
key='convert' key="convert"
type='dashed' type="dashed"
disabled={job.converted} disabled={job.converted}
onClick={() => { onClick={() => {
mutationConvertJob({ mutationConvertJob({
@@ -98,11 +101,12 @@ export function JobsDetailHeader({
message: t("jobs.successes.converted"), message: t("jobs.successes.converted"),
}); });
}); });
}}> }}
>
{t("jobs.actions.convert")} {t("jobs.actions.convert")}
</Button>, </Button>,
<JobsDetailHeaderActions key='actions' job={job} refetch={refetch} />, <JobsDetailHeaderActions key="actions" job={job} refetch={refetch} />,
<Button type='primary' key='submit' htmlType='submit'> <Button type="primary" key="submit" htmlType="submit">
{t("general.actions.save")} {t("general.actions.save")}
</Button>, </Button>,
]; ];
@@ -115,48 +119,53 @@ export function JobsDetailHeader({
title={tombstoneTitle} title={tombstoneTitle}
//subTitle={tombstoneSubtitle} //subTitle={tombstoneSubtitle}
tags={ tags={
<span key='job-status'> <span key="job-status">
{job.status ? <Tag color='blue'>{job.status}</Tag> : null} {job.status ? <Tag color="blue">{job.status}</Tag> : null}
{job.inproduction ? ( {job.inproduction ? (
<Tag color='#f50'>{t("jobs.labels.inproduction")}</Tag> <Tag color="#f50">{t("jobs.labels.inproduction")}</Tag>
) : null} ) : null}
<OwnerTagPopoverComponent job={job} /> <OwnerTagPopoverComponent job={job} />
<VehicleTagPopoverComponent job={job} /> <VehicleTagPopoverComponent job={job} />
<BarcodePopup value={job.id} /> <BarcodePopup value={job.id} />
</span> </span>
} }
extra={menuExtra}> extra={menuExtra}
<Descriptions size='small' column={5}> >
<Descriptions.Item key='total' label={t("jobs.fields.repairtotal")}> <Descriptions size="small" column={5}>
<Descriptions.Item key="total" label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter> <CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item <Descriptions.Item
key='custowing' key="custowing"
label={t("jobs.fields.customerowing")}> label={t("jobs.fields.customerowing")}
>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter> <CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item <Descriptions.Item
key='scp' key="scp"
label={t("jobs.fields.specialcoveragepolicy")}> label={t("jobs.fields.specialcoveragepolicy")}
>
<Checkbox checked={job.special_coverage_policy} /> <Checkbox checked={job.special_coverage_policy} />
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item <Descriptions.Item
key='sched_comp' key="sched_comp"
label={t("jobs.fields.scheduled_completion")}> label={t("jobs.fields.scheduled_completion")}
>
{job.scheduled_completion ? ( {job.scheduled_completion ? (
<Moment format='MM/DD/YYYY'>{job.scheduled_completion}</Moment> <Moment format="MM/DD/YYYY">{job.scheduled_completion}</Moment>
) : null} ) : null}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item key='servicecar' label={t("jobs.fields.servicecar")}> <Descriptions.Item key="servicecar" label={t("jobs.fields.servicecar")}>
{job.cccontracts && {job.cccontracts &&
job.cccontracts.map((item) => ( job.cccontracts.map((item) => (
<Link <Link
key={item.id} key={item.id}
to={`/manage/courtesycars/contracts/${item.id}`}> to={`/manage/courtesycars/contracts/${item.id}`}
>
<div>{`${item.agreementnumber} - ${item.start} - ${item.scheduledreturn}`}</div> <div>{`${item.agreementnumber} - ${item.start} - ${item.scheduledreturn}`}</div>
</Link> </Link>
))} ))}

View File

@@ -15,16 +15,16 @@ export default function ScheduleCalendarWrapperComponent({
refetch, refetch,
defaultView, defaultView,
setDateRangeCallback, setDateRangeCallback,
date,
...otherProps ...otherProps
}) { }) {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const history = useHistory(); const history = useHistory();
return ( return (
<Calendar <Calendar
events={data} events={data}
defaultView={search.view || defaultView || "week"} defaultView={search.view || defaultView || "week"}
date={new Date(search.date || Date.now())} date={new Date(date || search.date || Date.now())}
onNavigate={(date, view, action) => { onNavigate={(date, view, action) => {
search.date = date.toISOString().substr(0, 10); search.date = date.toISOString().substr(0, 10);
history.push({ search: queryString.stringify(search) }); history.push({ search: queryString.stringify(search) });
@@ -45,6 +45,7 @@ export default function ScheduleCalendarWrapperComponent({
components={{ components={{
event: (e) => Event({ event: e.event, refetch: refetch }), event: (e) => Event({ event: e.event, refetch: refetch }),
header: HeaderComponent, header: HeaderComponent,
toolbar: null,
}} }}
{...otherProps} {...otherProps}
/> />

View File

@@ -6,15 +6,13 @@ import ScheduleCalendarWrapperComponent from "../schedule-calendar-wrapper/sched
export default function ScheduleDayViewComponent({ data, day }) { export default function ScheduleDayViewComponent({ data, day }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (data) if (data)
//TODO Remove addtional calendar elements from day view.
return ( return (
<ScheduleCalendarWrapperComponent <ScheduleCalendarWrapperComponent
events={data} events={data}
defaultView="day" defaultView="day"
views={["day"]} views={["day"]}
style={{ height: "40vh" }} style={{ height: "40vh" }}
defaultDate={new Date(day)} date={day}
//onNavigate={e => console.log("e", e)}
/> />
); );
else return <div>{t("appointments.labels.nodateselected")}</div>; else return <div>{t("appointments.labels.nodateselected")}</div>;

View File

@@ -4,22 +4,24 @@ import { useQuery } from "@apollo/react-hooks";
import { QUERY_APPOINTMENT_BY_DATE } from "../../graphql/appointments.queries"; import { QUERY_APPOINTMENT_BY_DATE } from "../../graphql/appointments.queries";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import moment from "moment"; import moment from "moment";
import { useTranslation } from "react-i18next";
export default function ScheduleDayViewContainer({ day }) { export default function ScheduleDayViewContainer({ day }) {
const { loading, error, data } = useQuery(QUERY_APPOINTMENT_BY_DATE, { const { loading, error, data } = useQuery(QUERY_APPOINTMENT_BY_DATE, {
variables: { variables: {
start: moment(day).startOf("day"), start: moment(day).startOf("day"),
end: moment(day).endOf("day") end: moment(day).endOf("day"),
}, },
skip: !day, skip: !!!day,
fetchPolicy: "network-only" fetchPolicy: "network-only",
}); });
const { t } = useTranslation();
if (!!!day) return <div>{t("appointments.labels.nodateselected")}</div>;
if (loading) return <LoadingSkeleton paragraph={{ rows: 4 }} />; if (loading) return <LoadingSkeleton paragraph={{ rows: 4 }} />;
if (error) return <div>{error.message}</div>; if (error) return <div>{error.message}</div>;
let normalizedData; let normalizedData;
if (data) { if (data) {
normalizedData = data.appointments.map(e => { normalizedData = data.appointments.map((e) => {
//Required becuase Hasura returns a string instead of a date object. //Required becuase Hasura returns a string instead of a date object.
return Object.assign( return Object.assign(
{}, {},
@@ -31,6 +33,6 @@ export default function ScheduleDayViewContainer({ day }) {
} }
return ( return (
<ScheduleDayViewComponent data={data ? normalizedData : null} day={day} /> <ScheduleDayViewComponent data={data ? normalizedData : []} day={day} />
); );
} }

View File

@@ -1,4 +1,4 @@
import { Checkbox, Col, Row, Tabs } from "antd"; import { Checkbox, Col, Row, Input, Button } from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component"; import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
@@ -6,6 +6,7 @@ import ScheduleDayViewContainer from "../schedule-day-view/schedule-day-view.con
import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component"; import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component";
import axios from "axios"; import axios from "axios";
import { auth } from "../../firebase/firebase.utils"; import { auth } from "../../firebase/firebase.utils";
import EmailInput from "../form-items-formatted/email-form-item.component";
export default function ScheduleJobModalComponent({ export default function ScheduleJobModalComponent({
existingAppointments, existingAppointments,
@@ -37,31 +38,24 @@ export default function ScheduleJobModalComponent({
return ( return (
<Row> <Row>
<Col span={14}> <Col span={14}>
<Tabs defaultActiveKey="1"> <div style={{ display: "flex", alignContent: "middle" }}>
<Tabs.TabPane tab="SMART Scheduling" key="auto"> <strong>{t("appointments.fields.time")}</strong>
Automatic Job Selection. <DateTimePicker
<button onClick={handleAuto}>Get dates.</button> value={appData.start}
</Tabs.TabPane> onChange={(e) => {
<Tabs.TabPane tab="Manual Scheduling" key="manual"> setAppData({ ...appData, start: e });
<Row> }}
Manual Job Selection Scheduled Time />
<div style={{ height: "300px" }}> <Button onClick={handleAuto}>
<DateTimePicker {t("appointments.actions.smartscheduling")}
value={appData.start} </Button>
onChange={(e) => { </div>
setAppData({ ...appData, start: e });
}} {t("appointments.labels.history")}
/>
</div>
</Row>
</Tabs.TabPane>
</Tabs>
<ScheduleExistingAppointmentsList <ScheduleExistingAppointmentsList
existingAppointments={existingAppointments} existingAppointments={existingAppointments}
/> />
{
//TODO Build out notifications.
}
<Checkbox <Checkbox
defaultChecked={formData.notifyCustomer} defaultChecked={formData.notifyCustomer}
onChange={(e) => onChange={(e) =>
@@ -70,6 +64,11 @@ export default function ScheduleJobModalComponent({
> >
{t("jobs.labels.appointmentconfirmation")} {t("jobs.labels.appointmentconfirmation")}
</Checkbox> </Checkbox>
<EmailInput
defaultValue={formData.email}
title={t("owner.fields.ownr_ea")}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</Col> </Col>
<Col span={10}> <Col span={10}>
<ScheduleDayViewContainer day={appData.start} /> <ScheduleDayViewContainer day={appData.start} />

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import ScheduleJobModalComponent from "./schedule-job-modal.component"; import ScheduleJobModalComponent from "./schedule-job-modal.component";
import { useMutation, useQuery } from "@apollo/react-hooks"; import { useMutation, useQuery } from "@apollo/react-hooks";
import { import {
@@ -9,12 +9,13 @@ import moment from "moment";
import { notification, Modal } from "antd"; import { notification, Modal } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UPDATE_JOBS } from "../../graphql/jobs.queries"; import { UPDATE_JOBS } from "../../graphql/jobs.queries";
import { setEmailOptions } from "../../redux/email/email.actions";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectSchedule } from "../../redux/modals/modals.selectors"; import { selectSchedule } from "../../redux/modals/modals.selectors";
import { toggleModalVisible } from "../../redux/modals/modals.actions"; import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { TemplateList } from "../../utils/TemplateConstants";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -22,22 +23,37 @@ const mapStateToProps = createStructuredSelector({
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("schedule")), toggleModalVisible: () => dispatch(toggleModalVisible("schedule")),
setEmailOptions: (e) => dispatch(setEmailOptions(e)),
}); });
export function ScheduleJobModalContainer({ export function ScheduleJobModalContainer({
scheduleModal, scheduleModal,
bodyshop, bodyshop,
toggleModalVisible, toggleModalVisible,
setEmailOptions,
}) { }) {
const { visible, context, actions } = scheduleModal; const { visible, context, actions } = scheduleModal;
const { jobId } = context; const { jobId, job } = context;
const { refetch } = actions; const { refetch } = actions;
const [appData, setAppData] = useState({ const [appData, setAppData] = useState({
start: null, start: null,
}); });
const [insertAppointment] = useMutation(INSERT_APPOINTMENT); const [insertAppointment] = useMutation(INSERT_APPOINTMENT);
const [updateJobStatus] = useMutation(UPDATE_JOBS); const [updateJobStatus] = useMutation(UPDATE_JOBS);
const [formData, setFormData] = useState({ notifyCustomer: false }); const [formData, setFormData] = useState({
notifyCustomer: false,
email: (job && job.ownr_ea) || "",
});
useEffect(() => {
setFormData({
notifyCustomer: !!(job && job.ownr_ea),
email: (job && job.ownr_ea) || "",
start: null,
});
}, [job, setFormData]);
const { t } = useTranslation(); const { t } = useTranslation();
const existingAppointments = useQuery(QUERY_APPOINTMENTS_BY_JOBID, { const existingAppointments = useQuery(QUERY_APPOINTMENTS_BY_JOBID, {
@@ -47,47 +63,67 @@ export function ScheduleJobModalContainer({
}); });
//TODO Customize the amount of minutes it will add. //TODO Customize the amount of minutes it will add.
const handleOk = () => { const handleOk = async () => {
insertAppointment({ const appt = await insertAppointment({
variables: { variables: {
app: { app: {
...appData, ...appData,
jobid: jobId, jobid: jobId,
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
end: moment(appData.start).add(60, "minutes"), end: moment(appData.start).add(bodyshop.appt_length || 60, "minutes"),
}, },
}, },
}) });
.then((r) => {
updateJobStatus({
variables: {
jobIds: [jobId],
fields: {
status: bodyshop.md_ro_statuses.default_scheduled,
date_scheduled: new Date(),
scheduled_in: appData.start,
},
},
}).then((r) => {
notification["success"]({
message: t("appointments.successes.created"),
});
if (formData.notifyCustomer) { if (!!appt.errors) {
//TODO Implement customer reminder on scheduling. notification["error"]({
alert("Chosed to notify the customer somehow!"); message: t("appointments.errors.saving", {
} message: JSON.stringify(appt.errors),
toggleModalVisible(); }),
if (refetch) refetch(); });
}); return;
}) }
.catch((error) => { notification["success"]({
message: t("appointments.successes.created"),
});
if (jobId) {
const jobUpdate = await updateJobStatus({
variables: {
jobIds: [jobId],
fields: {
status: bodyshop.md_ro_statuses.default_scheduled,
date_scheduled: new Date(),
scheduled_in: appData.start,
},
},
});
if (!!jobUpdate.errors) {
notification["error"]({ notification["error"]({
message: t("appointments.errors.saving", { message: t("appointments.errors.saving", {
message: error.message, message: JSON.stringify(jobUpdate.errors),
}), }),
}); });
return;
}
}
toggleModalVisible();
if (formData.notifyCustomer) {
setEmailOptions({
messageOptions: {
to: formData.email,
replyTo: bodyshop.email,
},
template: {
name: TemplateList.appointment_confirmation.key,
variables: {
id: appt.data.insert_appointments.returning[0].id,
},
},
}); });
}
if (refetch) refetch();
}; };
return ( return (

View File

@@ -9,71 +9,92 @@ export default function ShopInfoComponent({ form }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div> <div>
<Button type='primary' htmlType='submit'> <Button type="primary" htmlType="submit">
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
<Collapse defaultActiveKey='shopinfo'> <Collapse defaultActiveKey="shopinfo">
<Collapse.Panel key='shopinfo' header={t("bodyshop.labels.shopinfo")}> <Collapse.Panel key="shopinfo" header={t("bodyshop.labels.shopinfo")}>
<Form.Item label={t("bodyshop.fields.shopname")} name='shopname'> <Form.Item label={t("bodyshop.fields.shopname")} name="shopname">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("bodyshop.fields.address1")} name='address1'> <Form.Item label={t("bodyshop.fields.address1")} name="address1">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("bodyshop.fields.address2")} name='address2'> <Form.Item label={t("bodyshop.fields.address2")} name="address2">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("bodyshop.fields.city")} name='city'> <Form.Item label={t("bodyshop.fields.city")} name="city">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("bodyshop.fields.state")} name='state'> <Form.Item label={t("bodyshop.fields.state")} name="state">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("bodyshop.fields.zip_post")} name='zip_post'> <Form.Item label={t("bodyshop.fields.zip_post")} name="zip_post">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("bodyshop.fields.country")} name='country'> <Form.Item label={t("bodyshop.fields.country")} name="country">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item label={t("bodyshop.fields.email")} name='email'> <Form.Item label={t("bodyshop.fields.email")} name="email">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.federal_tax_id")} label={t("bodyshop.fields.federal_tax_id")}
name='federal_tax_id'> name="federal_tax_id"
>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.insurance_vendor_id")} label={t("bodyshop.fields.insurance_vendor_id")}
name='insurance_vendor_id'> name="insurance_vendor_id"
>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.logo_img_path")} label={t("bodyshop.fields.logo_img_path")}
name='logo_img_path'> name="logo_img_path"
>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.state_tax_id")} label={t("bodyshop.fields.state_tax_id")}
name='state_tax_id'> name="state_tax_id"
>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.invoice_federal_tax_rate")} label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["invoice_tax_rates", "federal_tax_rate"]}> name={["invoice_tax_rates", "federal_tax_rate"]}
>
<InputNumber /> <InputNumber />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.invoice_state_tax_rate")} label={t("bodyshop.fields.invoice_state_tax_rate")}
name={["invoice_tax_rates", "state_tax_rate"]}> name={["invoice_tax_rates", "state_tax_rate"]}
>
<InputNumber /> <InputNumber />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.fields.invoice_local_tax_rate")} label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["invoice_tax_rates", "local_tax_rate"]}> name={["invoice_tax_rates", "local_tax_rate"]}
>
<InputNumber /> <InputNumber />
</Form.Item> </Form.Item>
<Form.Item
label={t("bodyshop.fields.appt_length")}
name={"appt_length"}
rules={[
{
required: true,
message: t("general.validation.required"),
},
]}
>
<InputNumber min={15} precisio={0} />
</Form.Item>
<Form.Item <Form.Item
label={t("bodyshop.labels.accountingtiers")} label={t("bodyshop.labels.accountingtiers")}
rules={[ rules={[
@@ -82,7 +103,8 @@ export default function ShopInfoComponent({ form }) {
message: t("general.validation.required"), message: t("general.validation.required"),
}, },
]} ]}
name={["accountingconfig", "tiers"]}> name={["accountingconfig", "tiers"]}
>
<Radio.Group> <Radio.Group>
<Radio value={2}>2</Radio> <Radio value={2}>2</Radio>
<Radio value={3}>3</Radio> <Radio value={3}>3</Radio>
@@ -101,13 +123,15 @@ export default function ShopInfoComponent({ form }) {
message: t("general.validation.required"), message: t("general.validation.required"),
}, },
]} ]}
name={["accountingconfig", "twotierpref"]}> name={["accountingconfig", "twotierpref"]}
>
<Radio.Group <Radio.Group
disabled={ disabled={
form.getFieldValue(["accountingconfig", "tiers"]) === 3 form.getFieldValue(["accountingconfig", "tiers"]) === 3
}> }
<Radio value='name'>{t("bodyshop.labels.2tiername")}</Radio> >
<Radio value='source'> <Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">
{t("bodyshop.labels.2tiersource")} {t("bodyshop.labels.2tiersource")}
</Radio> </Radio>
</Radio.Group> </Radio.Group>
@@ -117,18 +141,21 @@ export default function ShopInfoComponent({ form }) {
</Form.Item> </Form.Item>
</Collapse.Panel> </Collapse.Panel>
<Collapse.Panel <Collapse.Panel
key='roStatus' key="roStatus"
header={t("bodyshop.labels.jobstatuses")}> header={t("bodyshop.labels.jobstatuses")}
>
<ShopInfoROStatusComponent form={form} /> <ShopInfoROStatusComponent form={form} />
</Collapse.Panel> </Collapse.Panel>
<Collapse.Panel <Collapse.Panel
key='orderStatus' key="orderStatus"
header={t("bodyshop.labels.orderstatuses")}> header={t("bodyshop.labels.orderstatuses")}
>
<ShopInfoOrderStatusComponent form={form} /> <ShopInfoOrderStatusComponent form={form} />
</Collapse.Panel> </Collapse.Panel>
<Collapse.Panel <Collapse.Panel
key='responsibilityCenters' key="responsibilityCenters"
header={t("bodyshop.labels.responsibilitycenters.title")}> header={t("bodyshop.labels.responsibilitycenters.title")}
>
<ShopInfoResponsibilityCenterComponent form={form} /> <ShopInfoResponsibilityCenterComponent form={form} />
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>

View File

@@ -78,6 +78,7 @@ export const UPDATE_SHOP = gql`
textid textid
production_config production_config
invoice_tax_rates invoice_tax_rates
appt_length
employees { employees {
id id
first_name first_name

View File

@@ -28,9 +28,10 @@ export default function CsiContainerPage() {
return ( return (
<div> <div>
<Result <Result
status='error' status="error"
title={t("csi.errors.notfoundtitle")} title={t("csi.errors.notfoundtitle")}
subTitle={t("csi.errors.notfoundsubtitle")}> subTitle={t("csi.errors.notfoundsubtitle")}
>
{error ? ( {error ? (
<div>ERROR: {error.graphQLErrors.map((e) => e.message)}</div> <div>ERROR: {error.graphQLErrors.map((e) => e.message)}</div>
) : null} ) : null}
@@ -70,18 +71,20 @@ export default function CsiContainerPage() {
return ( return (
<Layout <Layout
style={{ height: "100vh", display: "flex", flexDirection: "column" }}> style={{ height: "100vh", display: "flex", flexDirection: "column" }}
>
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
}}> }}
>
<div style={{ display: "flex", alignItems: "center", margin: "2em" }}> <div style={{ display: "flex", alignItems: "center", margin: "2em" }}>
{bodyshop.logo_img_path ? ( {bodyshop.logo_img_path ? (
<img src={bodyshop.logo_img_path} alt='Logo' /> <img src={bodyshop.logo_img_path} alt="Logo" />
) : null} ) : null}
<div> <div style={{ margin: "2em" }}>
<strong>{bodyshop.shopname || ""}</strong> <strong>{bodyshop.shopname || ""}</strong>
<div>{`${bodyshop.address1 || ""}`}</div> <div>{`${bodyshop.address1 || ""}`}</div>
<div>{`${bodyshop.address2 || ""}`}</div> <div>{`${bodyshop.address2 || ""}`}</div>
@@ -93,13 +96,15 @@ export default function CsiContainerPage() {
<Typography.Title>{t("csi.labels.title")}</Typography.Title> <Typography.Title>{t("csi.labels.title")}</Typography.Title>
<strong>{`Hi ${job.ownr_fn || ""}!`}</strong> <strong>{`Hi ${job.ownr_fn || ""}!`}</strong>
<Typography.Paragraph> <Typography.Paragraph>
At {bodyshop.shopname || ""}, we value your feedback. We would love to {`At ${
hear what you have to say. Please fill out the form below. bodyshop.shopname || ""
}, we value your feedback. We would love to
hear what you have to say. Please fill out the form below.`}
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
{submitting.error ? ( {submitting.error ? (
<AlertComponent message={submitting.error} type='error' /> <AlertComponent message={submitting.error} type="error" />
) : null} ) : null}
{submitting.submitted ? ( {submitting.submitted ? (
@@ -109,9 +114,10 @@ export default function CsiContainerPage() {
margin: "2em 4em", margin: "2em 4em",
padding: "2em", padding: "2em",
overflowY: "auto", overflowY: "auto",
}}> }}
>
<Result <Result
status='success' status="success"
title={t("csi.successes.submitted")} title={t("csi.successes.submitted")}
subTitle={t("csi.successes.submittedsub")} subTitle={t("csi.successes.submittedsub")}
/> />
@@ -123,13 +129,15 @@ export default function CsiContainerPage() {
margin: "2em 4em", margin: "2em 4em",
padding: "2em", padding: "2em",
overflowY: "auto", overflowY: "auto",
}}> }}
>
<Form form={form} onFinish={handleFinish}> <Form form={form} onFinish={handleFinish}>
<ConfigFormComponents componentList={csiquestions} /> <ConfigFormComponents componentList={csiquestions} />
<Button <Button
loading={submitting.loading} loading={submitting.loading}
type='primary' type="primary"
htmlType='submit'> htmlType="submit"
>
{t("general.actions.submit")} {t("general.actions.submit")}
</Button> </Button>
</Form> </Form>
@@ -137,7 +145,7 @@ export default function CsiContainerPage() {
)} )}
<Layout.Footer> <Layout.Footer>
Copyright ImEX.Online. Survey ID: {surveyId} {`Copyright ImEX.Online. Survey ID: ${surveyId}`}
</Layout.Footer> </Layout.Footer>
</Layout> </Layout>
); );

View File

@@ -23,6 +23,7 @@
"intake": "Intake", "intake": "Intake",
"new": "New Appointment", "new": "New Appointment",
"reschedule": "Reschedule", "reschedule": "Reschedule",
"smartscheduling": "SMART Scheduling",
"viewjob": "View Job" "viewjob": "View Job"
}, },
"errors": { "errors": {
@@ -30,11 +31,13 @@
"saving": "Error scheduling appointment. {{message}}" "saving": "Error scheduling appointment. {{message}}"
}, },
"fields": { "fields": {
"time": "Appointment Time",
"title": "Title" "title": "Title"
}, },
"labels": { "labels": {
"arrivedon": "Arrived on: ", "arrivedon": "Arrived on: ",
"cancelledappointment": "Canceled appointment for: ", "cancelledappointment": "Canceled appointment for: ",
"history": "History",
"nodateselected": "No date has been selected.", "nodateselected": "No date has been selected.",
"priorappointments": "Previous Appointments", "priorappointments": "Previous Appointments",
"scheduledfor": "Scheduled appointment for: " "scheduledfor": "Scheduled appointment for: "
@@ -76,6 +79,7 @@
"fields": { "fields": {
"address1": "Address 1", "address1": "Address 1",
"address2": "Address 2", "address2": "Address 2",
"appt_length": "Default Appointment Length",
"city": "City", "city": "City",
"country": "Country", "country": "Country",
"email": "General Shop Email", "email": "General Shop Email",

View File

@@ -23,6 +23,7 @@
"intake": "Consumo", "intake": "Consumo",
"new": "Nueva cita", "new": "Nueva cita",
"reschedule": "Reprogramar", "reschedule": "Reprogramar",
"smartscheduling": "",
"viewjob": "Ver trabajo" "viewjob": "Ver trabajo"
}, },
"errors": { "errors": {
@@ -30,11 +31,13 @@
"saving": "Error al programar la cita. {{message}}" "saving": "Error al programar la cita. {{message}}"
}, },
"fields": { "fields": {
"time": "",
"title": "Título" "title": "Título"
}, },
"labels": { "labels": {
"arrivedon": "Llegado el:", "arrivedon": "Llegado el:",
"cancelledappointment": "Cita cancelada para:", "cancelledappointment": "Cita cancelada para:",
"history": "",
"nodateselected": "No se ha seleccionado ninguna fecha.", "nodateselected": "No se ha seleccionado ninguna fecha.",
"priorappointments": "Nombramientos previos", "priorappointments": "Nombramientos previos",
"scheduledfor": "Cita programada para:" "scheduledfor": "Cita programada para:"
@@ -76,6 +79,7 @@
"fields": { "fields": {
"address1": "", "address1": "",
"address2": "", "address2": "",
"appt_length": "",
"city": "", "city": "",
"country": "", "country": "",
"email": "", "email": "",

View File

@@ -23,6 +23,7 @@
"intake": "Admission", "intake": "Admission",
"new": "Nouveau rendez-vous", "new": "Nouveau rendez-vous",
"reschedule": "Replanifier", "reschedule": "Replanifier",
"smartscheduling": "",
"viewjob": "Voir le travail" "viewjob": "Voir le travail"
}, },
"errors": { "errors": {
@@ -30,11 +31,13 @@
"saving": "Erreur lors de la planification du rendez-vous. {{message}}" "saving": "Erreur lors de la planification du rendez-vous. {{message}}"
}, },
"fields": { "fields": {
"time": "",
"title": "Titre" "title": "Titre"
}, },
"labels": { "labels": {
"arrivedon": "Arrivé le:", "arrivedon": "Arrivé le:",
"cancelledappointment": "Rendez-vous annulé pour:", "cancelledappointment": "Rendez-vous annulé pour:",
"history": "",
"nodateselected": "Aucune date n'a été sélectionnée.", "nodateselected": "Aucune date n'a été sélectionnée.",
"priorappointments": "Rendez-vous précédents", "priorappointments": "Rendez-vous précédents",
"scheduledfor": "Rendez-vous prévu pour:" "scheduledfor": "Rendez-vous prévu pour:"
@@ -76,6 +79,7 @@
"fields": { "fields": {
"address1": "", "address1": "",
"address2": "", "address2": "",
"appt_length": "",
"city": "", "city": "",
"country": "", "country": "",
"email": "", "email": "",

View File

@@ -10,6 +10,13 @@ export const TemplateList = {
drivingId: "Appointment Id", drivingId: "Appointment Id",
key: "appointment_reminder", key: "appointment_reminder",
}, },
appointment_confirmation: {
title: "Appointment Confirmation",
description:
"Sent to a customer as a Confirmation of an upcoming appointment.",
drivingId: "Appointment Id",
key: "appointment_confirmation",
},
parts_order_confirmation: { parts_order_confirmation: {
title: "Parts Order Confirmation", title: "Parts Order Confirmation",
description: "Parts order template including part details", description: "Parts order template including part details",

View File

@@ -0,0 +1,11 @@
query EMAIL_APPOINTMENT_CONFIRMATION($id: uuid!) {
appointments_by_pk(id: $id) {
start
title
job {
ownr_fn
ownr_ln
ownr_ea
}
}
}

View File

@@ -0,0 +1,9 @@
<div style="font-family: Arial, Helvetica, sans-serif;">
<p style="text-align: center;">Hello {{appointments_by_pk.job.ownr_fn}},</p>
<p style="text-align: center;">
This is a confirmation that you have an appointment at
{{appointments_by_pk.start}} to bring your car in for repair. Please email
us at {{bodyshop.email}} if you can't make it.&nbsp;
</p>
</div>

View File

@@ -0,0 +1,5 @@
- args:
cascade: false
read_only: false
sql: ALTER TABLE "public"."bodyshops" DROP COLUMN "appt_length";
type: run_sql

View File

@@ -0,0 +1,6 @@
- args:
cascade: false
read_only: false
sql: ALTER TABLE "public"."bodyshops" ADD COLUMN "appt_length" integer NOT NULL
DEFAULT 60;
type: run_sql

View File

@@ -0,0 +1,51 @@
- args:
role: user
table:
name: bodyshops
schema: public
type: drop_select_permission
- args:
permission:
allow_aggregations: false
columns:
- accountingconfig
- address1
- address2
- city
- country
- created_at
- email
- federal_tax_id
- id
- inhousevendorid
- insurance_vendor_id
- intakechecklist
- invoice_tax_rates
- logo_img_path
- md_order_statuses
- md_responsibility_centers
- md_ro_statuses
- messagingservicesid
- production_config
- region_config
- shopname
- shoprates
- state
- state_tax_id
- template_header
- textid
- updated_at
- zip_post
computed_fields: []
filter:
associations:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
role: user
table:
name: bodyshops
schema: public
type: create_select_permission

View File

@@ -0,0 +1,52 @@
- args:
role: user
table:
name: bodyshops
schema: public
type: drop_select_permission
- args:
permission:
allow_aggregations: false
columns:
- accountingconfig
- address1
- address2
- appt_length
- city
- country
- created_at
- email
- federal_tax_id
- id
- inhousevendorid
- insurance_vendor_id
- intakechecklist
- invoice_tax_rates
- logo_img_path
- md_order_statuses
- md_responsibility_centers
- md_ro_statuses
- messagingservicesid
- production_config
- region_config
- shopname
- shoprates
- state
- state_tax_id
- template_header
- textid
- updated_at
- zip_post
computed_fields: []
filter:
associations:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
role: user
table:
name: bodyshops
schema: public
type: create_select_permission

View File

@@ -0,0 +1,49 @@
- args:
role: user
table:
name: bodyshops
schema: public
type: drop_update_permission
- args:
permission:
columns:
- accountingconfig
- address1
- address2
- city
- country
- created_at
- email
- federal_tax_id
- id
- inhousevendorid
- insurance_vendor_id
- intakechecklist
- invoice_tax_rates
- logo_img_path
- md_order_statuses
- md_responsibility_centers
- md_ro_statuses
- production_config
- shopname
- shoprates
- state
- state_tax_id
- updated_at
- zip_post
filter:
associations:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
localPresets:
- key: ""
value: ""
set: {}
role: user
table:
name: bodyshops
schema: public
type: create_update_permission

View File

@@ -0,0 +1,50 @@
- args:
role: user
table:
name: bodyshops
schema: public
type: drop_update_permission
- args:
permission:
columns:
- accountingconfig
- address1
- address2
- appt_length
- city
- country
- created_at
- email
- federal_tax_id
- id
- inhousevendorid
- insurance_vendor_id
- intakechecklist
- invoice_tax_rates
- logo_img_path
- md_order_statuses
- md_responsibility_centers
- md_ro_statuses
- production_config
- shopname
- shoprates
- state
- state_tax_id
- updated_at
- zip_post
filter:
associations:
bodyshop:
associations:
user:
authid:
_eq: X-Hasura-User-Id
localPresets:
- key: ""
value: ""
set: {}
role: user
table:
name: bodyshops
schema: public
type: create_update_permission

View File

@@ -447,6 +447,7 @@ tables:
- accountingconfig - accountingconfig
- address1 - address1
- address2 - address2
- appt_length
- city - city
- country - country
- created_at - created_at
@@ -486,6 +487,7 @@ tables:
- accountingconfig - accountingconfig
- address1 - address1
- address2 - address2
- appt_length
- city - city
- country - country
- created_at - created_at

View File

@@ -27,6 +27,8 @@
"firebase-admin": "^8.11.0", "firebase-admin": "^8.11.0",
"graphql-request": "^1.8.2", "graphql-request": "^1.8.2",
"handlebars": "^4.7.6", "handlebars": "^4.7.6",
"lodash": "^4.17.15",
"moment": "^2.26.0",
"nodemailer": "^6.4.4", "nodemailer": "^6.4.4",
"phone": "^2.4.8", "phone": "^2.4.8",
"twilio": "^3.41.1", "twilio": "^3.41.1",

View File

@@ -1,13 +1,73 @@
const path = require("path"); const path = require("path");
const moment = require("moment");
require("dotenv").config({ require("dotenv").config({
path: path.resolve( path: path.resolve(
process.cwd(), process.cwd(),
`.env.${process.env.NODE_ENV || "development"}` `.env.${process.env.NODE_ENV || "development"}`
), ),
}); });
var _ = require("lodash");
const Handlebars = require("handlebars"); const Handlebars = require("handlebars");
Handlebars.registerHelper("moment", function (context, block) {
if (context && context.hash) {
block = _.cloneDeep(context);
context = undefined;
}
var date = moment(context);
if (block.hash.timezone) {
date.tz(block.hash.timezone);
}
var hasFormat = false;
// Reset the language back to default before doing anything else
date.locale("en");
for (var i in block.hash) {
if (i === "format") {
hasFormat = true;
} else if (date[i]) {
date = date[i](block.hash[i]);
} else {
console.log('moment.js does not support "' + i + '"');
}
}
if (hasFormat) {
date = date.format(block.hash.format);
}
return date;
});
Handlebars.registerHelper("duration", function (context, block) {
if (context && context.hash) {
block = _.cloneDeep(context);
context = 0;
}
var duration = moment.duration(context);
var hasFormat = false;
// Reset the language back to default before doing anything else
duration = duration.lang("en");
for (var i in block.hash) {
if (i === "format") {
hasFormat = true;
} else if (duration[i]) {
duration = duration[i](block.hash[i]);
} else {
console.log('moment.js duration does not support "' + i + '"');
}
}
if (hasFormat) {
duration = duration.format(block.hash.format);
}
return duration;
});
exports.render = (req, res) => { exports.render = (req, res) => {
//Perform request validation //Perform request validation
let view; let view;

View File

@@ -1992,6 +1992,11 @@ mkdirp@^0.5.1:
dependencies: dependencies:
minimist "0.0.8" minimist "0.0.8"
moment@^2.26.0:
version "2.26.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a"
integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw==
ms@2.0.0: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"