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>
</translations>
</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>
<name>viewjob</name>
<definition_loaded>false</definition_loaded>
@@ -353,6 +374,27 @@
<folder_node>
<name>fields</name>
<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>
<name>title</name>
<definition_loaded>false</definition_loaded>
@@ -421,6 +463,27 @@
</translation>
</translations>
</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>
<name>nodateselected</name>
<definition_loaded>false</definition_loaded>
@@ -875,6 +938,27 @@
</translation>
</translations>
</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>
<name>city</name>
<definition_loaded>false</definition_loaded>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,15 +6,13 @@ import ScheduleCalendarWrapperComponent from "../schedule-calendar-wrapper/sched
export default function ScheduleDayViewComponent({ data, day }) {
const { t } = useTranslation();
if (data)
//TODO Remove addtional calendar elements from day view.
return (
<ScheduleCalendarWrapperComponent
events={data}
defaultView="day"
views={["day"]}
style={{ height: "40vh" }}
defaultDate={new Date(day)}
//onNavigate={e => console.log("e", e)}
date={day}
/>
);
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 LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import moment from "moment";
import { useTranslation } from "react-i18next";
export default function ScheduleDayViewContainer({ day }) {
const { loading, error, data } = useQuery(QUERY_APPOINTMENT_BY_DATE, {
variables: {
start: moment(day).startOf("day"),
end: moment(day).endOf("day")
end: moment(day).endOf("day"),
},
skip: !day,
fetchPolicy: "network-only"
skip: !!!day,
fetchPolicy: "network-only",
});
const { t } = useTranslation();
if (!!!day) return <div>{t("appointments.labels.nodateselected")}</div>;
if (loading) return <LoadingSkeleton paragraph={{ rows: 4 }} />;
if (error) return <div>{error.message}</div>;
let normalizedData;
if (data) {
normalizedData = data.appointments.map(e => {
normalizedData = data.appointments.map((e) => {
//Required becuase Hasura returns a string instead of a date object.
return Object.assign(
{},
@@ -31,6 +33,6 @@ export default function ScheduleDayViewContainer({ day }) {
}
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 { useTranslation } from "react-i18next";
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 axios from "axios";
import { auth } from "../../firebase/firebase.utils";
import EmailInput from "../form-items-formatted/email-form-item.component";
export default function ScheduleJobModalComponent({
existingAppointments,
@@ -37,31 +38,24 @@ export default function ScheduleJobModalComponent({
return (
<Row>
<Col span={14}>
<Tabs defaultActiveKey="1">
<Tabs.TabPane tab="SMART Scheduling" key="auto">
Automatic Job Selection.
<button onClick={handleAuto}>Get dates.</button>
</Tabs.TabPane>
<Tabs.TabPane tab="Manual Scheduling" key="manual">
<Row>
Manual Job Selection Scheduled Time
<div style={{ height: "300px" }}>
<DateTimePicker
value={appData.start}
onChange={(e) => {
setAppData({ ...appData, start: e });
}}
/>
</div>
</Row>
</Tabs.TabPane>
</Tabs>
<div style={{ display: "flex", alignContent: "middle" }}>
<strong>{t("appointments.fields.time")}</strong>
<DateTimePicker
value={appData.start}
onChange={(e) => {
setAppData({ ...appData, start: e });
}}
/>
<Button onClick={handleAuto}>
{t("appointments.actions.smartscheduling")}
</Button>
</div>
{t("appointments.labels.history")}
<ScheduleExistingAppointmentsList
existingAppointments={existingAppointments}
/>
{
//TODO Build out notifications.
}
<Checkbox
defaultChecked={formData.notifyCustomer}
onChange={(e) =>
@@ -70,6 +64,11 @@ export default function ScheduleJobModalComponent({
>
{t("jobs.labels.appointmentconfirmation")}
</Checkbox>
<EmailInput
defaultValue={formData.email}
title={t("owner.fields.ownr_ea")}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</Col>
<Col span={10}>
<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 { useMutation, useQuery } from "@apollo/react-hooks";
import {
@@ -9,12 +9,13 @@ import moment from "moment";
import { notification, Modal } from "antd";
import { useTranslation } from "react-i18next";
import { UPDATE_JOBS } from "../../graphql/jobs.queries";
import { setEmailOptions } from "../../redux/email/email.actions";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectSchedule } from "../../redux/modals/modals.selectors";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { TemplateList } from "../../utils/TemplateConstants";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -22,22 +23,37 @@ const mapStateToProps = createStructuredSelector({
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("schedule")),
setEmailOptions: (e) => dispatch(setEmailOptions(e)),
});
export function ScheduleJobModalContainer({
scheduleModal,
bodyshop,
toggleModalVisible,
setEmailOptions,
}) {
const { visible, context, actions } = scheduleModal;
const { jobId } = context;
const { jobId, job } = context;
const { refetch } = actions;
const [appData, setAppData] = useState({
start: null,
});
const [insertAppointment] = useMutation(INSERT_APPOINTMENT);
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 existingAppointments = useQuery(QUERY_APPOINTMENTS_BY_JOBID, {
@@ -47,47 +63,67 @@ export function ScheduleJobModalContainer({
});
//TODO Customize the amount of minutes it will add.
const handleOk = () => {
insertAppointment({
const handleOk = async () => {
const appt = await insertAppointment({
variables: {
app: {
...appData,
jobid: jobId,
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) {
//TODO Implement customer reminder on scheduling.
alert("Chosed to notify the customer somehow!");
}
toggleModalVisible();
if (refetch) refetch();
});
})
.catch((error) => {
if (!!appt.errors) {
notification["error"]({
message: t("appointments.errors.saving", {
message: JSON.stringify(appt.errors),
}),
});
return;
}
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"]({
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 (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,13 @@ export const TemplateList = {
drivingId: "Appointment Id",
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: {
title: "Parts Order Confirmation",
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
- address1
- address2
- appt_length
- city
- country
- created_at
@@ -486,6 +487,7 @@ tables:
- accountingconfig
- address1
- address2
- appt_length
- city
- country
- created_at

View File

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

View File

@@ -1,13 +1,73 @@
const path = require("path");
const moment = require("moment");
require("dotenv").config({
path: path.resolve(
process.cwd(),
`.env.${process.env.NODE_ENV || "development"}`
),
});
var _ = require("lodash");
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) => {
//Perform request validation
let view;

View File

@@ -1992,6 +1992,11 @@ mkdirp@^0.5.1:
dependencies:
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:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"