IO-306 Creation of dashboard.
This commit is contained in:
@@ -4858,6 +4858,27 @@
|
|||||||
<folder_node>
|
<folder_node>
|
||||||
<name>shop</name>
|
<name>shop</name>
|
||||||
<children>
|
<children>
|
||||||
|
<concept_node>
|
||||||
|
<name>dashboard</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>rbac</name>
|
<name>rbac</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -10534,6 +10555,95 @@
|
|||||||
</concept_node>
|
</concept_node>
|
||||||
</children>
|
</children>
|
||||||
</folder_node>
|
</folder_node>
|
||||||
|
<folder_node>
|
||||||
|
<name>labels</name>
|
||||||
|
<children>
|
||||||
|
<concept_node>
|
||||||
|
<name>bodyhrs</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>dollarsinproduction</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>prodhrs</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>refhrs</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>
|
||||||
|
</children>
|
||||||
|
</folder_node>
|
||||||
<folder_node>
|
<folder_node>
|
||||||
<name>titles</name>
|
<name>titles</name>
|
||||||
<children>
|
<children>
|
||||||
@@ -12904,6 +13014,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>globalsearch</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>hours</name>
|
<name>hours</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -24009,6 +24140,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>dashboard</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>enterbills</name>
|
<name>enterbills</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -34094,6 +34246,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>dashboard</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>export-logs</name>
|
<name>export-logs</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
@@ -34852,6 +35025,27 @@
|
|||||||
</translation>
|
</translation>
|
||||||
</translations>
|
</translations>
|
||||||
</concept_node>
|
</concept_node>
|
||||||
|
<concept_node>
|
||||||
|
<name>dashboard</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>export-logs</name>
|
<name>export-logs</name>
|
||||||
<definition_loaded>false</definition_loaded>
|
<definition_loaded>false</definition_loaded>
|
||||||
|
|||||||
23797
client/package-lock.json
generated
23797
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
|||||||
"proxy": "http://localhost:5000",
|
"proxy": "http://localhost:5000",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.3.17",
|
"@apollo/client": "^3.3.17",
|
||||||
"@craco/craco": "^6.1.2",
|
"@craco/craco": "^5.9.0",
|
||||||
"@fingerprintjs/fingerprintjs": "^3.1.2",
|
"@fingerprintjs/fingerprintjs": "^3.1.2",
|
||||||
"@lourenci/react-kanban": "^2.1.0",
|
"@lourenci/react-kanban": "^2.1.0",
|
||||||
"@sentry/react": "^6.3.6",
|
"@sentry/react": "^6.3.6",
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-drag-listview": "^0.1.8",
|
"react-drag-listview": "^0.1.8",
|
||||||
"react-grid-gallery": "^0.5.5",
|
"react-grid-gallery": "^0.5.5",
|
||||||
|
"react-grid-layout": "^1.2.5",
|
||||||
"react-i18next": "^11.8.15",
|
"react-i18next": "^11.8.15",
|
||||||
"react-icons": "^4.2.0",
|
"react-icons": "^4.2.0",
|
||||||
"react-number-format": "^4.5.5",
|
"react-number-format": "^4.5.5",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { connect } from "react-redux";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBreadcrumbs } from "../../redux/application/application.selectors";
|
import { selectBreadcrumbs } from "../../redux/application/application.selectors";
|
||||||
|
import GlobalSearch from "../global-search/global-search.component";
|
||||||
import "./breadcrumbs.styles.scss";
|
import "./breadcrumbs.styles.scss";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
@@ -14,7 +15,7 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
export function BreadCrumbs({ breadcrumbs }) {
|
export function BreadCrumbs({ breadcrumbs }) {
|
||||||
return (
|
return (
|
||||||
<div className="breadcrumb-container imex-flex-row">
|
<div className="breadcrumb-container imex-flex-row">
|
||||||
<Breadcrumb separator=">">
|
<Breadcrumb separator=">" style={{ flex: 1 }}>
|
||||||
<Breadcrumb.Item>
|
<Breadcrumb.Item>
|
||||||
<Link to={`/manage`}>
|
<Link to={`/manage`}>
|
||||||
<HomeFilled />
|
<HomeFilled />
|
||||||
@@ -30,6 +31,9 @@ export function BreadCrumbs({ breadcrumbs }) {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<div>
|
||||||
|
<GlobalSearch />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,32 @@ import { Card } from "antd";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import _ from "lodash";
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
Bar,
|
Bar,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Legend,
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import Dinero from "dinero.js";
|
||||||
import * as Utils from "../../scoreboard-targets-table/scoreboard-targets-table.util";
|
import * as Utils from "../../scoreboard-targets-table/scoreboard-targets-table.util";
|
||||||
|
|
||||||
export default function DashboardMonthlyRevenueGraph({ data, ...cardProps }) {
|
export default function DashboardMonthlyRevenueGraph({ data, ...cardProps }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
const jobsByDate = {
|
const jobsByDate = _.groupBy(data.monthly_sales, (item) =>
|
||||||
"2020-07-5": [{ clm_total: 1224 }],
|
moment(item.date_invoiced).format("YYYY-MM-DD")
|
||||||
"2020-07-8": [{ clm_total: 987 }, { clm_total: 8755 }],
|
);
|
||||||
"2020-07-12": [{ clm_total: 684 }, { clm_total: 12022 }],
|
console.log(
|
||||||
"2020-07-21": [{ clm_total: 15000 }],
|
"🚀 ~ file: monthly-revenue-graph.component.jsx ~ line 27 ~ jobsByDate",
|
||||||
"2020-07-28": [{ clm_total: 122 }, { clm_total: 4522 }],
|
jobsByDate
|
||||||
};
|
);
|
||||||
|
|
||||||
const listOfDays = Utils.ListOfDaysInCurrentMonth();
|
const listOfDays = Utils.ListOfDaysInCurrentMonth();
|
||||||
|
|
||||||
@@ -33,17 +36,19 @@ export default function DashboardMonthlyRevenueGraph({ data, ...cardProps }) {
|
|||||||
let dailySales;
|
let dailySales;
|
||||||
if (!!jobsByDate[val]) {
|
if (!!jobsByDate[val]) {
|
||||||
dailySales = jobsByDate[val].reduce((dayAcc, dayVal) => {
|
dailySales = jobsByDate[val].reduce((dayAcc, dayVal) => {
|
||||||
return dayAcc + dayVal.clm_total;
|
return dayAcc.add(Dinero(dayVal.job_totals.totals.subtotal));
|
||||||
}, 0);
|
}, Dinero());
|
||||||
} else {
|
} else {
|
||||||
dailySales = 0;
|
dailySales = Dinero();
|
||||||
}
|
}
|
||||||
|
|
||||||
const theValue = {
|
const theValue = {
|
||||||
date: moment(val).format("D dd"),
|
date: moment(val).format("DD"),
|
||||||
dailySales,
|
dailySales: dailySales.getAmount() / 100,
|
||||||
accSales:
|
accSales:
|
||||||
acc.length > 0 ? acc[acc.length - 1].accSales + dailySales : dailySales,
|
acc.length > 0
|
||||||
|
? acc[acc.length - 1].accSales + dailySales.getAmount() / 100
|
||||||
|
: dailySales.getAmount() / 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
return [...acc, theValue];
|
return [...acc, theValue];
|
||||||
@@ -80,3 +85,15 @@ export default function DashboardMonthlyRevenueGraph({ data, ...cardProps }) {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DashboardMonthlyRevenueGraphGql = `
|
||||||
|
monthly_sales: jobs(where: {_and: [{date_invoiced: {_gte: "${moment()
|
||||||
|
.startOf("month")
|
||||||
|
.format("YYYY-MM-DD")}"}}, {date_invoiced: {_lte: "${moment()
|
||||||
|
.endOf("month")
|
||||||
|
.format("YYYY-MM-DD")}"}}]}) {
|
||||||
|
id
|
||||||
|
date_invoiced
|
||||||
|
job_totals
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -1,32 +1,25 @@
|
|||||||
import React from "react";
|
|
||||||
import { Card, Statistic } from "antd";
|
import { Card, Statistic } from "antd";
|
||||||
|
import Dinero from "dinero.js";
|
||||||
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
|
|
||||||
|
|
||||||
export default function DashboardTotalProductionDollars({
|
export default function DashboardTotalProductionDollars({
|
||||||
data,
|
data,
|
||||||
...cardProps
|
...cardProps
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const aboveTargetProductionDollars = false;
|
if (!data) return null;
|
||||||
|
|
||||||
|
const dollars = data.production_jobs.reduce(
|
||||||
|
(acc, val) => acc.add(Dinero(val.job_totals.totals.subtotal)),
|
||||||
|
Dinero()
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card {...cardProps}>
|
<Card {...cardProps}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t("dashboard.titles.productiondollars")}
|
title={t("dashboard.labels.dollarsinproduction")}
|
||||||
value={175000.0}
|
value={dollars.toFormat()}
|
||||||
precision={2}
|
|
||||||
prefix={
|
|
||||||
<div>
|
|
||||||
{aboveTargetProductionDollars ? (
|
|
||||||
<ArrowUpOutlined />
|
|
||||||
) : (
|
|
||||||
<ArrowDownOutlined />
|
|
||||||
)}
|
|
||||||
$
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
valueStyle={{ color: aboveTargetProductionDollars ? "green" : "red" }}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,54 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, Statistic } from "antd";
|
import { Card, Space, Statistic } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
|
import { ArrowDownOutlined, ArrowUpOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
export default function DashboardTotalProductionHours({ data, ...cardProps }) {
|
import { connect } from "react-redux";
|
||||||
|
import { createStructuredSelector } from "reselect";
|
||||||
|
import { selectBodyshop } from "../../../redux/user/user.selectors";
|
||||||
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
bodyshop: selectBodyshop,
|
||||||
|
});
|
||||||
|
const mapDispatchToProps = (dispatch) => ({});
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(DashboardTotalProductionHours);
|
||||||
|
|
||||||
|
export function DashboardTotalProductionHours({
|
||||||
|
bodyshop,
|
||||||
|
data,
|
||||||
|
...cardProps
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const aboveTargetHours = true;
|
if (!data) return null;
|
||||||
|
const hours = data.production_jobs.reduce(
|
||||||
|
(acc, val) => {
|
||||||
|
return {
|
||||||
|
body: acc.body + val.labhrs.aggregate.sum.mod_lb_hrs,
|
||||||
|
ref: acc.ref + val.larhrs.aggregate.sum.mod_lb_hrs,
|
||||||
|
total:
|
||||||
|
acc.total +
|
||||||
|
val.labhrs.aggregate.sum.mod_lb_hrs +
|
||||||
|
val.larhrs.aggregate.sum.mod_lb_hrs,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ body: 0, ref: 0, total: 0 }
|
||||||
|
);
|
||||||
|
const aboveTargetHours = hours.total >= bodyshop.prodtargethrs;
|
||||||
return (
|
return (
|
||||||
<Card {...cardProps}>
|
<Card {...cardProps}>
|
||||||
<Statistic
|
<Space wrap style={{ flex: 1 }}>
|
||||||
title={t("dashboard.titles.productionhours")}
|
<Statistic title={t("dashboard.labels.bodyhrs")} value={hours.body} />
|
||||||
value={750}
|
<Statistic title={t("dashboard.labels.refhrs")} value={hours.ref} />
|
||||||
prefix={aboveTargetHours ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
<Statistic
|
||||||
valueStyle={{ color: aboveTargetHours ? "green" : "red" }}
|
title={t("dashboard.labels.prodhrs")}
|
||||||
/>
|
value={hours.total}
|
||||||
|
valueStyle={{ color: aboveTargetHours ? "green" : "red" }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DashboardTotalProductionHoursGql = ``;
|
||||||
|
|||||||
@@ -1,185 +1,249 @@
|
|||||||
// import Icon from "@ant-design/icons";
|
import Icon, { SyncOutlined } from "@ant-design/icons";
|
||||||
// import { Button, Dropdown, Menu, notification } from "antd";
|
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||||
// import React, { useState } from "react";
|
import { Button, Dropdown, Menu, notification, PageHeader, Space } from "antd";
|
||||||
// import { useMutation, useQuery } from "@apollo/client";
|
import React, { useState } from "react";
|
||||||
// import { Responsive, WidthProvider } from "react-grid-layout";
|
import { Responsive, WidthProvider } from "react-grid-layout";
|
||||||
// import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
// import { MdClose } from "react-icons/md";
|
import { MdClose } from "react-icons/md";
|
||||||
// import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
// import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
// import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
// import { QUERY_DASHBOARD_DETAILS } from "../../graphql/bodyshop.queries";
|
import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
|
||||||
// import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
|
import {
|
||||||
// import {
|
selectBodyshop,
|
||||||
// selectBodyshop,
|
selectCurrentUser,
|
||||||
// selectCurrentUser,
|
} from "../../redux/user/user.selectors";
|
||||||
// } from "../../redux/user/user.selectors";
|
import AlertComponent from "../alert/alert.component";
|
||||||
// import AlertComponent from "../alert/alert.component";
|
import DashboardMonthlyRevenueGraph, {
|
||||||
// import DashboardMonthlyRevenueGraph from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component";
|
DashboardMonthlyRevenueGraphGql,
|
||||||
// import DashboardProjectedMonthlySales from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component";
|
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component";
|
||||||
// import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component";
|
import DashboardProjectedMonthlySales from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component";
|
||||||
// import DashboardTotalProductionHours from "../dashboard-components/total-production-hours/total-production-hours.component";
|
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component";
|
||||||
// import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
import DashboardTotalProductionHours, {
|
||||||
// //Combination of the following:
|
DashboardTotalProductionHoursGql,
|
||||||
// // /node_modules/react-grid-layout/css/styles.css
|
} from "../dashboard-components/total-production-hours/total-production-hours.component";
|
||||||
// // /node_modules/react-resizable/css/styles.css
|
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||||
// import "./dashboard-grid.styles.css";
|
//Combination of the following:
|
||||||
// import "./dashboard-grid.styles.scss";
|
// /node_modules/react-grid-layout/css/styles.css
|
||||||
|
// /node_modules/react-resizable/css/styles.css
|
||||||
|
import "./dashboard-grid.styles.scss";
|
||||||
|
import { GenerateDashboardData } from "./dashboard-grid.utils";
|
||||||
|
|
||||||
// const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
||||||
|
|
||||||
// const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
// currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
// bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
// });
|
});
|
||||||
// const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
// //setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
// });
|
});
|
||||||
|
|
||||||
// export function DashboardGridComponent({ currentUser, bodyshop }) {
|
export function DashboardGridComponent({ currentUser, bodyshop }) {
|
||||||
// const { loading, error, data } = useQuery(QUERY_DASHBOARD_DETAILS);
|
const { t } = useTranslation();
|
||||||
// const { t } = useTranslation();
|
const [state, setState] = useState({
|
||||||
// const [state, setState] = useState({
|
...(bodyshop.associations[0].user.dashboardlayout
|
||||||
// layout: bodyshop.associations[0].user.dashboardlayout || [
|
? bodyshop.associations[0].user.dashboardlayout
|
||||||
// { i: "ProductionDollars", x: 0, y: 0, w: 2, h: 2 },
|
: { items: [], layout: [], layouts: [] }),
|
||||||
// // { i: "ProductionHours", x: 2, y: 0, w: 2, h: 2 },
|
});
|
||||||
// ],
|
const { loading, error, data, refetch } = useQuery(
|
||||||
// });
|
createDashboardQuery(state)
|
||||||
// const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT);
|
);
|
||||||
|
|
||||||
// const handleLayoutChange = async (newLayout) => {
|
const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT);
|
||||||
// logImEXEvent("dashboard_change_layout");
|
|
||||||
// setState({ ...state, layout: newLayout });
|
|
||||||
// const result = await updateLayout({
|
|
||||||
// variables: { email: currentUser.email, layout: newLayout },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (!!result.errors) {
|
const handleLayoutChange = async (layout, layouts) => {
|
||||||
// notification["error"]({
|
logImEXEvent("dashboard_change_layout");
|
||||||
// message: t("dashboard.errors.updatinglayout", {
|
|
||||||
// message: JSON.stringify(result.errors),
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleRemoveComponent = (key) => {
|
setState({ ...state, layout, layouts });
|
||||||
// logImEXEvent("dashboard_remove_component", { name: key });
|
|
||||||
|
|
||||||
// const idxToRemove = state.layout.findIndex((i) => i.i === key);
|
const result = await updateLayout({
|
||||||
// const newLayout = state.layout;
|
variables: {
|
||||||
// newLayout.splice(idxToRemove, 1);
|
email: currentUser.email,
|
||||||
// handleLayoutChange(newLayout);
|
layout: { ...state, layout, layouts },
|
||||||
// };
|
},
|
||||||
|
});
|
||||||
|
if (!!result.errors) {
|
||||||
|
notification["error"]({
|
||||||
|
message: t("dashboard.errors.updatinglayout", {
|
||||||
|
message: JSON.stringify(result.errors),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleRemoveComponent = (key) => {
|
||||||
|
logImEXEvent("dashboard_remove_component", { name: key });
|
||||||
|
const idxToRemove = state.items.findIndex((i) => i.i === key);
|
||||||
|
const items = state.items;
|
||||||
|
items.splice(idxToRemove, 1);
|
||||||
|
setState({ ...state, items });
|
||||||
|
};
|
||||||
|
|
||||||
// const handleAddComponent = (e) => {
|
const handleAddComponent = (e) => {
|
||||||
// logImEXEvent("dashboard_add_component", { name: e });
|
logImEXEvent("dashboard_add_component", { name: e });
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
i: e.key,
|
||||||
|
x: (state.items.length * 2) % (state.cols || 12),
|
||||||
|
y: Infinity, // puts it at the bottom
|
||||||
|
w: componentList[e.key].w || 2,
|
||||||
|
h: componentList[e.key].h || 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// handleLayoutChange([
|
const dashboarddata = React.useMemo(
|
||||||
// ...state.layout,
|
() => GenerateDashboardData(data),
|
||||||
// {
|
[data]
|
||||||
// i: e.key,
|
);
|
||||||
// x: (state.layout.length * 2) % (state.cols || 12),
|
|
||||||
// y: Infinity, // puts it at the bottom
|
|
||||||
// w: componentList[e.key].w || 2,
|
|
||||||
// h: componentList[e.key].h || 2,
|
|
||||||
// },
|
|
||||||
// ]);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const onBreakpointChange = (breakpoint, cols) => {
|
// const onBreakpointChange = (breakpoint, cols) => {
|
||||||
// setState({ ...state, breakpoint: breakpoint, cols: cols });
|
// setState({ ...state, breakpoint: breakpoint, cols: cols });
|
||||||
// };
|
// };
|
||||||
|
|
||||||
// const existingLayoutKeys = state.layout.map((i) => i.i);
|
const existingLayoutKeys = state.items.map((i) => i.i);
|
||||||
// const addComponentOverlay = (
|
const addComponentOverlay = (
|
||||||
// <Menu onClick={handleAddComponent}>
|
<Menu onClick={handleAddComponent}>
|
||||||
// {Object.keys(componentList).map((key) => (
|
{Object.keys(componentList).map((key) => (
|
||||||
// <Menu.Item
|
<Menu.Item
|
||||||
// key={key}
|
key={key}
|
||||||
// value={key}
|
value={key}
|
||||||
// disabled={existingLayoutKeys.includes(key)}
|
disabled={existingLayoutKeys.includes(key)}
|
||||||
// >
|
>
|
||||||
// {componentList[key].label}
|
{componentList[key].label}
|
||||||
// </Menu.Item>
|
</Menu.Item>
|
||||||
// ))}
|
))}
|
||||||
// </Menu>
|
</Menu>
|
||||||
// );
|
);
|
||||||
|
|
||||||
// if (error) return <AlertComponent message={error.message} type="error" />;
|
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||||
|
|
||||||
// return (
|
return (
|
||||||
// <div>
|
<div>
|
||||||
// <Dropdown overlay={addComponentOverlay} trigger={["click"]}>
|
<PageHeader
|
||||||
// <Button>{t("dashboard.actions.addcomponent")}</Button>
|
extra={
|
||||||
// </Dropdown>
|
<Space>
|
||||||
// <ResponsiveReactGridLayout
|
<Button onClick={() => refetch()}>
|
||||||
// className="layout"
|
<SyncOutlined />
|
||||||
// breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
</Button>
|
||||||
// cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
<Dropdown overlay={addComponentOverlay} trigger={["click"]}>
|
||||||
// width="100%"
|
<Button>{t("dashboard.actions.addcomponent")}</Button>
|
||||||
// onLayoutChange={handleLayoutChange}
|
</Dropdown>
|
||||||
// onBreakpointChange={onBreakpointChange}
|
</Space>
|
||||||
// >
|
}
|
||||||
// {state.layout.map((item, index) => {
|
/>
|
||||||
// const TheComponent = componentList[item.i].component;
|
|
||||||
// return (
|
|
||||||
// <div key={item.i} data-grid={item}>
|
|
||||||
// <LoadingSkeleton loading={loading}>
|
|
||||||
// <Icon
|
|
||||||
// component={MdClose}
|
|
||||||
// key={item.i}
|
|
||||||
// style={{
|
|
||||||
// position: "absolute",
|
|
||||||
// zIndex: "2",
|
|
||||||
// right: ".25rem",
|
|
||||||
// top: ".25rem",
|
|
||||||
// cursor: "pointer",
|
|
||||||
// }}
|
|
||||||
// onClick={() => handleRemoveComponent(item.i)}
|
|
||||||
// />
|
|
||||||
// <TheComponent
|
|
||||||
// className="dashboard-card"
|
|
||||||
// size="small"
|
|
||||||
// style={{ height: "100%", width: "100%" }}
|
|
||||||
// />
|
|
||||||
// </LoadingSkeleton>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// })}
|
|
||||||
// </ResponsiveReactGridLayout>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export default connect(
|
<ResponsiveReactGridLayout
|
||||||
// mapStateToProps,
|
className="layout"
|
||||||
// mapDispatchToProps
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
// )(DashboardGridComponent);
|
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||||
|
width="100%"
|
||||||
|
layouts={state.layouts}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
// onBreakpointChange={onBreakpointChange}
|
||||||
|
>
|
||||||
|
{state.items.map((item, index) => {
|
||||||
|
const TheComponent = componentList[item.i].component;
|
||||||
|
return (
|
||||||
|
<div key={item.i} data-grid={item}>
|
||||||
|
<LoadingSkeleton loading={loading}>
|
||||||
|
<Icon
|
||||||
|
component={MdClose}
|
||||||
|
key={item.i}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: "2",
|
||||||
|
right: ".25rem",
|
||||||
|
top: ".25rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveComponent(item.i)}
|
||||||
|
/>
|
||||||
|
<TheComponent className="dashboard-card" data={dashboarddata} />
|
||||||
|
</LoadingSkeleton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ResponsiveReactGridLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// const componentList = {
|
export default connect(
|
||||||
// ProductionDollars: {
|
mapStateToProps,
|
||||||
// label: "Production Dollars",
|
mapDispatchToProps
|
||||||
// component: DashboardTotalProductionDollars,
|
)(DashboardGridComponent);
|
||||||
// w: 2,
|
|
||||||
// h: 1,
|
const componentList = {
|
||||||
// },
|
ProductionDollars: {
|
||||||
// ProductionHours: {
|
label: "Production Dollars",
|
||||||
// label: "Production Hours",
|
component: DashboardTotalProductionDollars,
|
||||||
// component: DashboardTotalProductionHours,
|
gqlFragment: null,
|
||||||
// w: 2,
|
w: 2,
|
||||||
// h: 1,
|
h: 1,
|
||||||
// },
|
},
|
||||||
// ProjectedMonthlySales: {
|
ProductionHours: {
|
||||||
// label: "Projected Monthly Sales",
|
label: "Production Hours",
|
||||||
// component: DashboardProjectedMonthlySales,
|
component: DashboardTotalProductionHours,
|
||||||
// w: 2,
|
gqlFragment: DashboardTotalProductionHoursGql,
|
||||||
// h: 1,
|
w: 2,
|
||||||
// },
|
h: 1,
|
||||||
// MonthlyRevenueGraph: {
|
},
|
||||||
// label: "Monthly Sales Graph",
|
ProjectedMonthlySales: {
|
||||||
// component: DashboardMonthlyRevenueGraph,
|
label: "Projected Monthly Sales",
|
||||||
// w: 2,
|
component: DashboardProjectedMonthlySales,
|
||||||
// h: 2,
|
gqlFragment: null,
|
||||||
// },
|
w: 2,
|
||||||
// };
|
h: 1,
|
||||||
|
},
|
||||||
|
MonthlyRevenueGraph: {
|
||||||
|
label: "Monthly Sales Graph",
|
||||||
|
component: DashboardMonthlyRevenueGraph,
|
||||||
|
gqlFragment: DashboardMonthlyRevenueGraphGql,
|
||||||
|
w: 2,
|
||||||
|
h: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDashboardQuery = (state) => {
|
||||||
|
const componentBasedAdditions = state.layout
|
||||||
|
.map((item, index) => componentList[item.i].gqlFragment || "")
|
||||||
|
.join("");
|
||||||
|
return gql`
|
||||||
|
query QUERY_DASHBOARD_DETAILS {
|
||||||
|
${componentBasedAdditions}
|
||||||
|
production_jobs: jobs(where: { inproduction: { _eq: true } }) {
|
||||||
|
id
|
||||||
|
ro_number
|
||||||
|
ins_co_nm
|
||||||
|
job_totals
|
||||||
|
joblines(where: { removed: { _eq: false } }) {
|
||||||
|
id
|
||||||
|
mod_lbr_ty
|
||||||
|
mod_lb_hrs
|
||||||
|
act_price
|
||||||
|
part_qty
|
||||||
|
part_type
|
||||||
|
}
|
||||||
|
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" } }) {
|
||||||
|
aggregate {
|
||||||
|
sum {
|
||||||
|
mod_lb_hrs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" } }) {
|
||||||
|
aggregate {
|
||||||
|
sum {
|
||||||
|
mod_lb_hrs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
.react-resizable {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.react-resizable-handle {
|
|
||||||
position: absolute;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-origin: content-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmZmZmYwMCIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuMiBMIDQgNC4yIEwgNC4yIDQuMiBMIDQuMiAwIEwgNiAwIEwgNiA2IEwgNiA2IFoiIGZpbGw9IiMwMDAwMDAiLz48L2c+PC9zdmc+");
|
|
||||||
background-position: bottom right;
|
|
||||||
padding: 0 3px 3px 0;
|
|
||||||
}
|
|
||||||
.react-resizable-handle-sw {
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
cursor: sw-resize;
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
.react-resizable-handle-se {
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
cursor: se-resize;
|
|
||||||
}
|
|
||||||
.react-resizable-handle-nw {
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
cursor: nw-resize;
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
.react-resizable-handle-ne {
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
cursor: ne-resize;
|
|
||||||
transform: rotate(270deg);
|
|
||||||
}
|
|
||||||
.react-resizable-handle-w,
|
|
||||||
.react-resizable-handle-e {
|
|
||||||
top: 50%;
|
|
||||||
margin-top: -10px;
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
.react-resizable-handle-w {
|
|
||||||
left: 0;
|
|
||||||
transform: rotate(135deg);
|
|
||||||
}
|
|
||||||
.react-resizable-handle-e {
|
|
||||||
right: 0;
|
|
||||||
transform: rotate(315deg);
|
|
||||||
}
|
|
||||||
.react-resizable-handle-n,
|
|
||||||
.react-resizable-handle-s {
|
|
||||||
left: 50%;
|
|
||||||
margin-left: -10px;
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
.react-resizable-handle-n {
|
|
||||||
top: 0;
|
|
||||||
transform: rotate(225deg);
|
|
||||||
}
|
|
||||||
.react-resizable-handle-s {
|
|
||||||
bottom: 0;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
.react-grid-layout {
|
|
||||||
position: relative;
|
|
||||||
transition: height 200ms ease;
|
|
||||||
}
|
|
||||||
.react-grid-item {
|
|
||||||
transition: all 200ms ease;
|
|
||||||
transition-property: left, top;
|
|
||||||
}
|
|
||||||
.react-grid-item.cssTransforms {
|
|
||||||
transition-property: transform;
|
|
||||||
}
|
|
||||||
.react-grid-item.resizing {
|
|
||||||
z-index: 1;
|
|
||||||
will-change: width, height;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item.react-draggable-dragging {
|
|
||||||
transition: none;
|
|
||||||
z-index: 3;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item.dropping {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item.react-grid-placeholder {
|
|
||||||
background: red;
|
|
||||||
opacity: 0.2;
|
|
||||||
transition-duration: 100ms;
|
|
||||||
z-index: 2;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
-o-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item > .react-resizable-handle {
|
|
||||||
position: absolute;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
cursor: se-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-grid-item > .react-resizable-handle::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
right: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-right: 2px solid rgba(0, 0, 0, 0.4);
|
|
||||||
border-bottom: 2px solid rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-resizable-hide > .react-resizable-handle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,136 @@
|
|||||||
|
.react-resizable {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.react-resizable-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-origin: content-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmZmZmYwMCIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuMiBMIDQgNC4yIEwgNC4yIDQuMiBMIDQuMiAwIEwgNiAwIEwgNiA2IEwgNiA2IFoiIGZpbGw9IiMwMDAwMDAiLz48L2c+PC9zdmc+");
|
||||||
|
background-position: bottom right;
|
||||||
|
padding: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
.react-resizable-handle-sw {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
cursor: sw-resize;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.react-resizable-handle-se {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
.react-resizable-handle-nw {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
cursor: nw-resize;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.react-resizable-handle-ne {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: ne-resize;
|
||||||
|
transform: rotate(270deg);
|
||||||
|
}
|
||||||
|
.react-resizable-handle-w,
|
||||||
|
.react-resizable-handle-e {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -10px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
.react-resizable-handle-w {
|
||||||
|
left: 0;
|
||||||
|
transform: rotate(135deg);
|
||||||
|
}
|
||||||
|
.react-resizable-handle-e {
|
||||||
|
right: 0;
|
||||||
|
transform: rotate(315deg);
|
||||||
|
}
|
||||||
|
.react-resizable-handle-n,
|
||||||
|
.react-resizable-handle-s {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -10px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
.react-resizable-handle-n {
|
||||||
|
top: 0;
|
||||||
|
transform: rotate(225deg);
|
||||||
|
}
|
||||||
|
.react-resizable-handle-s {
|
||||||
|
bottom: 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.react-grid-layout {
|
||||||
|
position: relative;
|
||||||
|
transition: height 200ms ease;
|
||||||
|
}
|
||||||
|
.react-grid-item {
|
||||||
|
transition: all 200ms ease;
|
||||||
|
transition-property: left, top;
|
||||||
|
}
|
||||||
|
.react-grid-item.cssTransforms {
|
||||||
|
transition-property: transform;
|
||||||
|
}
|
||||||
|
.react-grid-item.resizing {
|
||||||
|
z-index: 1;
|
||||||
|
will-change: width, height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.react-draggable-dragging {
|
||||||
|
transition: none;
|
||||||
|
z-index: 3;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.dropping {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.react-grid-placeholder {
|
||||||
|
background: red;
|
||||||
|
opacity: 0.2;
|
||||||
|
transition-duration: 100ms;
|
||||||
|
z-index: 2;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-o-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-right: 2px solid rgba(0, 0, 0, 0.4);
|
||||||
|
border-bottom: 2px solid rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-resizable-hide > .react-resizable-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.dashboard-card {
|
.dashboard-card {
|
||||||
// background-color: green;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
.ant-card-body {
|
.ant-card-body {
|
||||||
// background-color: red;
|
// background-color: red;
|
||||||
height: 100%;
|
height: 90%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function GenerateDashboardData(data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -11,9 +11,8 @@ import AlertComponent from "../alert/alert.component";
|
|||||||
export default function GlobalSearch() {
|
export default function GlobalSearch() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [callSearch, { loading, error, data }] = useLazyQuery(
|
const [callSearch, { loading, error, data }] =
|
||||||
GLOBAL_SEARCH_QUERY
|
useLazyQuery(GLOBAL_SEARCH_QUERY);
|
||||||
);
|
|
||||||
|
|
||||||
const executeSearch = (v) => {
|
const executeSearch = (v) => {
|
||||||
if (v && v.variables.search && v.variables.search !== "") callSearch(v);
|
if (v && v.variables.search && v.variables.search !== "") callSearch(v);
|
||||||
@@ -166,10 +165,12 @@ export default function GlobalSearch() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
|
key="globalsearch"
|
||||||
dropdownMatchSelectWidth={"false"}
|
dropdownMatchSelectWidth={"false"}
|
||||||
options={options}
|
options={options}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
allowClear
|
allowClear
|
||||||
|
placeholder={t("general.labels.globalsearch")}
|
||||||
>
|
>
|
||||||
<Input.Search loading={loading} />
|
<Input.Search loading={loading} />
|
||||||
</AutoComplete>
|
</AutoComplete>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Icon, {
|
|||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
DashboardFilled,
|
||||||
ToolFilled,
|
ToolFilled,
|
||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -79,12 +80,12 @@ function Header({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Header style={{ display: "flex", alignItems: "center" }}>
|
<Layout.Header>
|
||||||
<Menu
|
<Menu
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
//theme="light"
|
//theme="light"
|
||||||
theme={"dark"}
|
theme={"dark"}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: "auto" }}
|
||||||
selectedKeys={[selectedHeader]}
|
selectedKeys={[selectedHeader]}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
subMenuCloseDelay={0.3}
|
subMenuCloseDelay={0.3}
|
||||||
@@ -96,6 +97,7 @@ function Header({
|
|||||||
<Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
<Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
|
key="jobssubmenu"
|
||||||
icon={<Icon component={FaCarCrash} />}
|
icon={<Icon component={FaCarCrash} />}
|
||||||
title={t("menus.header.jobs")}
|
title={t("menus.header.jobs")}
|
||||||
>
|
>
|
||||||
@@ -110,11 +112,11 @@ function Header({
|
|||||||
{t("menus.header.availablejobs")}
|
{t("menus.header.availablejobs")}
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider key="div1" />
|
||||||
<Menu.Item key="alljobs" icon={<UnorderedListOutlined />}>
|
<Menu.Item key="alljobs" icon={<UnorderedListOutlined />}>
|
||||||
<Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
<Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider key="div2" />
|
||||||
|
|
||||||
<Menu.Item key="productionlist" icon={<ScheduleOutlined />}>
|
<Menu.Item key="productionlist" icon={<ScheduleOutlined />}>
|
||||||
<Link to="/manage/production/list">
|
<Link to="/manage/production/list">
|
||||||
@@ -126,13 +128,14 @@ function Header({
|
|||||||
{t("menus.header.productionboard")}
|
{t("menus.header.productionboard")}
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider key="div3" />
|
||||||
|
|
||||||
<Menu.Item key="scoreboard" icon={<LineChartOutlined />}>
|
<Menu.Item key="scoreboard" icon={<LineChartOutlined />}>
|
||||||
<Link to="/manage/scoreboard">{t("menus.header.scoreboard")}</Link>
|
<Link to="/manage/scoreboard">{t("menus.header.scoreboard")}</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
|
key="customers"
|
||||||
icon={<UserOutlined />}
|
icon={<UserOutlined />}
|
||||||
title={t("menus.header.customers")}
|
title={t("menus.header.customers")}
|
||||||
>
|
>
|
||||||
@@ -144,6 +147,7 @@ function Header({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
|
key="ccs"
|
||||||
icon={<CarFilled />}
|
icon={<CarFilled />}
|
||||||
title={t("menus.header.courtesycars")}
|
title={t("menus.header.courtesycars")}
|
||||||
>
|
>
|
||||||
@@ -164,6 +168,7 @@ function Header({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
|
key="accounting"
|
||||||
icon={<DollarCircleFilled />}
|
icon={<DollarCircleFilled />}
|
||||||
title={t("menus.header.accounting")}
|
title={t("menus.header.accounting")}
|
||||||
>
|
>
|
||||||
@@ -185,7 +190,7 @@ function Header({
|
|||||||
>
|
>
|
||||||
{t("menus.header.enterbills")}
|
{t("menus.header.enterbills")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider key="div4" />
|
||||||
<Menu.Item key="allpayments" icon={<BankFilled />}>
|
<Menu.Item key="allpayments" icon={<BankFilled />}>
|
||||||
<Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
|
<Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -201,7 +206,7 @@ function Header({
|
|||||||
<Icon component={FaCreditCard} />
|
<Icon component={FaCreditCard} />
|
||||||
{t("menus.header.enterpayment")}
|
{t("menus.header.enterpayment")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider key="div5" />
|
||||||
|
|
||||||
<Menu.Item key="timetickets" icon={<FieldTimeOutlined />}>
|
<Menu.Item key="timetickets" icon={<FieldTimeOutlined />}>
|
||||||
<Link to="/manage/timetickets">
|
<Link to="/manage/timetickets">
|
||||||
@@ -220,9 +225,10 @@ function Header({
|
|||||||
>
|
>
|
||||||
{t("menus.header.entertimeticket")}
|
{t("menus.header.entertimeticket")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider key="div6" />
|
||||||
|
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
|
key="accountingexport"
|
||||||
title={t("menus.header.export")}
|
title={t("menus.header.export")}
|
||||||
icon={<ExportOutlined />}
|
icon={<ExportOutlined />}
|
||||||
>
|
>
|
||||||
@@ -256,18 +262,18 @@ function Header({
|
|||||||
{t("menus.header.temporarydocs")}
|
{t("menus.header.temporarydocs")}
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
|
||||||
key="help"
|
<Menu.SubMenu
|
||||||
onClick={() => {
|
key="shopsubmenu"
|
||||||
window.open("https://help.imex.online/", "_blank");
|
title={t("menus.header.shop")}
|
||||||
}}
|
icon={<SettingOutlined />}
|
||||||
icon={<Icon component={QuestionCircleFilled} />}
|
>
|
||||||
/>
|
|
||||||
<Menu.SubMenu title={t("menus.header.shop")} icon={<SettingOutlined />}>
|
|
||||||
<Menu.Item key="shop" icon={<Icon component={GiSettingsKnobs} />}>
|
<Menu.Item key="shop" icon={<Icon component={GiSettingsKnobs} />}>
|
||||||
<Link to="/manage/shop">{t("menus.header.shop_config")}</Link>
|
<Link to="/manage/shop">{t("menus.header.shop_config")}</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="dashboard" icon={<DashboardFilled />}>
|
||||||
|
<Link to="/manage/dashboard">{t("menus.header.dashboard")}</Link>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key="reportcenter"
|
key="reportcenter"
|
||||||
icon={<BarChartOutlined />}
|
icon={<BarChartOutlined />}
|
||||||
@@ -294,16 +300,25 @@ function Header({
|
|||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
style={{ float: "right" }}
|
style={{ float: "right" }}
|
||||||
|
key="user"
|
||||||
title={
|
title={
|
||||||
currentUser.displayName ||
|
currentUser.displayName ||
|
||||||
currentUser.email ||
|
currentUser.email ||
|
||||||
t("general.labels.unknown")
|
t("general.labels.unknown")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Menu.Item danger onClick={() => signOutStart()}>
|
<Menu.Item key="signout" danger onClick={() => signOutStart()}>
|
||||||
{t("user.actions.signout")}
|
{t("user.actions.signout")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
key="help"
|
||||||
|
onClick={() => {
|
||||||
|
window.open("https://help.imex.online/", "_blank");
|
||||||
|
}}
|
||||||
|
icon={<Icon component={QuestionCircleFilled} />}
|
||||||
|
/>
|
||||||
|
<Menu.Item
|
||||||
|
key="rescue"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open("https://imexrescue.com/", "_blank");
|
window.open("https://imexrescue.com/", "_blank");
|
||||||
}}
|
}}
|
||||||
@@ -317,6 +332,7 @@ function Header({
|
|||||||
<Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
|
<Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
|
key="langselecter"
|
||||||
title={
|
title={
|
||||||
<span>
|
<span>
|
||||||
<GlobalOutlined />
|
<GlobalOutlined />
|
||||||
@@ -335,7 +351,12 @@ function Header({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
<Menu.SubMenu style={{ float: "right" }} title={<ClockCircleFilled />}>
|
|
||||||
|
<Menu.SubMenu
|
||||||
|
key="recent"
|
||||||
|
style={{ float: "right" }}
|
||||||
|
title={<ClockCircleFilled />}
|
||||||
|
>
|
||||||
{recentItems.map((i, idx) => (
|
{recentItems.map((i, idx) => (
|
||||||
<Menu.Item key={idx}>
|
<Menu.Item key={idx}>
|
||||||
<Link to={i.url}>{i.label}</Link>
|
<Link to={i.url}>{i.label}</Link>
|
||||||
@@ -343,9 +364,6 @@ function Header({
|
|||||||
))}
|
))}
|
||||||
</Menu.SubMenu>
|
</Menu.SubMenu>
|
||||||
</Menu>
|
</Menu>
|
||||||
<div>
|
|
||||||
<GlobalSearch />
|
|
||||||
</div>
|
|
||||||
</Layout.Header>
|
</Layout.Header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function JobsDetailHeaderActions({
|
|||||||
? t("production.labels.alertoff")
|
? t("production.labels.alertoff")
|
||||||
: t("production.labels.alerton")}
|
: t("production.labels.alerton")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.SubMenu title={t("menus.jobsactions.duplicate")}>
|
<Menu.SubMenu key="dupe" title={t("menus.jobsactions.duplicate")}>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t("jobs.labels.duplicateconfirm")}
|
title={t("jobs.labels.duplicateconfirm")}
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export function JobsDetailHeaderCsi({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.SubMenu
|
<Menu.SubMenu
|
||||||
|
key="sendcsi"
|
||||||
title={t("jobs.actions.sendcsi")}
|
title={t("jobs.actions.sendcsi")}
|
||||||
disabled={!job.converted}
|
disabled={!job.converted}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const ret = {
|
|||||||
|
|
||||||
"shop:vendors": 2,
|
"shop:vendors": 2,
|
||||||
"shop:rbac": 1,
|
"shop:rbac": 1,
|
||||||
|
"shop:dashboard": 3,
|
||||||
"shop:templates": 4,
|
"shop:templates": 4,
|
||||||
|
|
||||||
"temporarydocs:view": 2,
|
"temporarydocs:view": 2,
|
||||||
|
|||||||
@@ -501,6 +501,18 @@ export default function ShopInfoRbacComponent({ form }) {
|
|||||||
>
|
>
|
||||||
<InputNumber />
|
<InputNumber />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.rbac.shop.dashboard")}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
//message: t("general.validation.required"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
name={["md_rbac", "shop:dashboard"]}
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("bodyshop.fields.rbac.shop.rbac")}
|
label={t("bodyshop.fields.rbac.shop.rbac")}
|
||||||
rules={[
|
rules={[
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function TimeTicketShiftContainer({
|
|||||||
if (loading) return <LoadingSpinner />;
|
if (loading) return <LoadingSpinner />;
|
||||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||||
|
|
||||||
if (!employeeId)
|
if (!employeeId && !(technician && technician.id))
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Result
|
<Result
|
||||||
|
|||||||
@@ -35,14 +35,18 @@ export const QUERY_ALL_ACTIVE_APPOINTMENTS = gql`
|
|||||||
v_model_yr
|
v_model_yr
|
||||||
v_make_desc
|
v_make_desc
|
||||||
v_model_desc
|
v_model_desc
|
||||||
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" } }) {
|
labhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" } }) {
|
larhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
@@ -128,14 +132,18 @@ export const QUERY_APPOINTMENT_BY_DATE = gql`
|
|||||||
v_make_desc
|
v_make_desc
|
||||||
v_model_desc
|
v_model_desc
|
||||||
}
|
}
|
||||||
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" } }) {
|
labhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" } }) {
|
larhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
@@ -197,14 +205,18 @@ export const QUERY_SCHEDULE_LOAD_DATA = gql`
|
|||||||
query QUERY_SCHEDULE_LOAD_DATA($start: timestamptz!, $end: timestamptz!) {
|
query QUERY_SCHEDULE_LOAD_DATA($start: timestamptz!, $end: timestamptz!) {
|
||||||
prodJobs: jobs(where: { inproduction: { _eq: true } }) {
|
prodJobs: jobs(where: { inproduction: { _eq: true } }) {
|
||||||
id
|
id
|
||||||
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" } }) {
|
labhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" } }) {
|
larhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
@@ -219,14 +231,18 @@ export const QUERY_SCHEDULE_LOAD_DATA = gql`
|
|||||||
|
|
||||||
ro_number
|
ro_number
|
||||||
scheduled_completion
|
scheduled_completion
|
||||||
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" } }) {
|
labhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" } }) {
|
larhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
@@ -234,19 +250,28 @@ export const QUERY_SCHEDULE_LOAD_DATA = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
arrJobs: jobs(where: { scheduled_in: { _gte: $start, _lte: $end } }) {
|
arrJobs: jobs(
|
||||||
|
where: {
|
||||||
|
scheduled_in: { _gte: $start, _lte: $end }
|
||||||
|
removed: { _eq: false }
|
||||||
|
}
|
||||||
|
) {
|
||||||
id
|
id
|
||||||
scheduled_in
|
scheduled_in
|
||||||
|
|
||||||
ro_number
|
ro_number
|
||||||
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" } }) {
|
labhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" } }) {
|
larhrs: joblines_aggregate(
|
||||||
|
where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }
|
||||||
|
) {
|
||||||
aggregate {
|
aggregate {
|
||||||
sum {
|
sum {
|
||||||
mod_lb_hrs
|
mod_lb_hrs
|
||||||
|
|||||||
@@ -243,33 +243,3 @@ export const QUERY_STRIPE_ID = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const QUERY_DASHBOARD_DETAILS = gql`
|
|
||||||
query QUERY_DASHBOARD_DETAILS {
|
|
||||||
jobs {
|
|
||||||
id
|
|
||||||
clm_total
|
|
||||||
scheduled_completion
|
|
||||||
date_invoiced
|
|
||||||
ins_co_nm
|
|
||||||
}
|
|
||||||
compJobs: jobs(where: { inproduction: { _eq: true } }) {
|
|
||||||
id
|
|
||||||
scheduled_completion
|
|
||||||
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" } }) {
|
|
||||||
aggregate {
|
|
||||||
sum {
|
|
||||||
mod_lb_hrs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" } }) {
|
|
||||||
aggregate {
|
|
||||||
sum {
|
|
||||||
mod_lb_hrs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|||||||
36
client/src/pages/dashboard/dashboard.container.jsx
Normal file
36
client/src/pages/dashboard/dashboard.container.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import DashboardGridComponent from "../../components/dashboard-grid/dashboard-grid.component";
|
||||||
|
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||||
|
import {
|
||||||
|
setBreadcrumbs,
|
||||||
|
setSelectedHeader,
|
||||||
|
} from "../../redux/application/application.actions";
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
|
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||||
|
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ExportsLogPageContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("titles.dashboard");
|
||||||
|
setSelectedHeader("dashboard");
|
||||||
|
setBreadcrumbs([
|
||||||
|
{
|
||||||
|
link: "/manage/accounting/exportlogs",
|
||||||
|
label: t("titles.bc.dashboard"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, [setBreadcrumbs, t, setSelectedHeader]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbacWrapper action="shop:dashboard">
|
||||||
|
<DashboardGridComponent />
|
||||||
|
</RbacWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default connect(null, mapDispatchToProps)(ExportsLogPageContainer);
|
||||||
@@ -158,6 +158,7 @@ const Phonebook = lazy(() => import("../phonebook/phonebook.page.container"));
|
|||||||
const EmailTest = lazy(() =>
|
const EmailTest = lazy(() =>
|
||||||
import("../../components/email-test/email-test-component")
|
import("../../components/email-test/email-test-component")
|
||||||
);
|
);
|
||||||
|
const Dashboard = lazy(() => import("../dashboard/dashboard.container"));
|
||||||
|
|
||||||
const { Content, Footer } = Layout;
|
const { Content, Footer } = Layout;
|
||||||
|
|
||||||
@@ -365,6 +366,7 @@ export function Manage({ match, conflict, bodyshop }) {
|
|||||||
/>
|
/>
|
||||||
<Route exact path={`${match.path}/help`} component={Help} />
|
<Route exact path={`${match.path}/help`} component={Help} />
|
||||||
<Route exact path={`${match.path}/emailtest`} component={EmailTest} />
|
<Route exact path={`${match.path}/emailtest`} component={EmailTest} />
|
||||||
|
<Route exact path={`${match.path}/dashboard`} component={Dashboard} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,7 @@
|
|||||||
"view": "Shift Clock -> View"
|
"view": "Shift Clock -> View"
|
||||||
},
|
},
|
||||||
"shop": {
|
"shop": {
|
||||||
|
"dashboard": "Shop -> Dashboard",
|
||||||
"rbac": "Shop -> RBAC",
|
"rbac": "Shop -> RBAC",
|
||||||
"templates": "Shop -> Templates",
|
"templates": "Shop -> Templates",
|
||||||
"vendors": "Shop -> Vendors"
|
"vendors": "Shop -> Vendors"
|
||||||
@@ -669,6 +670,12 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"updatinglayout": "Error saving updated layout {{message}}"
|
"updatinglayout": "Error saving updated layout {{message}}"
|
||||||
},
|
},
|
||||||
|
"labels": {
|
||||||
|
"bodyhrs": "Body Hrs",
|
||||||
|
"dollarsinproduction": "Dollars in Production",
|
||||||
|
"prodhrs": "Production Hrs",
|
||||||
|
"refhrs": "Refinish Hrs"
|
||||||
|
},
|
||||||
"titles": {
|
"titles": {
|
||||||
"monthlyrevenuegraph": "Monthly Revenue Graph",
|
"monthlyrevenuegraph": "Monthly Revenue Graph",
|
||||||
"productiondollars": "Total dollars in production",
|
"productiondollars": "Total dollars in production",
|
||||||
@@ -825,6 +832,7 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"exceptiontitle": "An error has occurred.",
|
"exceptiontitle": "An error has occurred.",
|
||||||
"friday": "Friday",
|
"friday": "Friday",
|
||||||
|
"globalsearch": "Global Search",
|
||||||
"hours": "hrs",
|
"hours": "hrs",
|
||||||
"in": "In",
|
"in": "In",
|
||||||
"instanceconflictext": "Your $t(titles.app) account can only be used on one device at any given time. Refresh your session to take control.",
|
"instanceconflictext": "Your $t(titles.app) account can only be used on one device at any given time. Refresh your session to take control.",
|
||||||
@@ -1419,6 +1427,7 @@
|
|||||||
"courtesycars-contracts": "Contracts",
|
"courtesycars-contracts": "Contracts",
|
||||||
"courtesycars-newcontract": "New Contract",
|
"courtesycars-newcontract": "New Contract",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
"enterbills": "Enter Bills",
|
"enterbills": "Enter Bills",
|
||||||
"enterpayment": "Enter Payments",
|
"enterpayment": "Enter Payments",
|
||||||
"entertimeticket": "Enter Time Tickets",
|
"entertimeticket": "Enter Time Tickets",
|
||||||
@@ -2049,6 +2058,7 @@
|
|||||||
"courtesycars": "Courtesy Cars",
|
"courtesycars": "Courtesy Cars",
|
||||||
"courtesycars-detail": "Courtesy Car {{number}}",
|
"courtesycars-detail": "Courtesy Car {{number}}",
|
||||||
"courtesycars-new": "New Courtesy Car",
|
"courtesycars-new": "New Courtesy Car",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
"export-logs": "Export Logs",
|
"export-logs": "Export Logs",
|
||||||
"jobs": "Jobs",
|
"jobs": "Jobs",
|
||||||
"jobs-active": "Active Jobs",
|
"jobs-active": "Active Jobs",
|
||||||
@@ -2086,6 +2096,7 @@
|
|||||||
"courtesycars": "Courtesy Cars | $t(titles.app)",
|
"courtesycars": "Courtesy Cars | $t(titles.app)",
|
||||||
"courtesycars-create": "New Courtesy Car | $t(titles.app)",
|
"courtesycars-create": "New Courtesy Car | $t(titles.app)",
|
||||||
"courtesycars-detail": "Courtesy Car {{id}} | $t(titles.app)",
|
"courtesycars-detail": "Courtesy Car {{id}} | $t(titles.app)",
|
||||||
|
"dashboard": "Dashboard | $t(titles.app)",
|
||||||
"export-logs": "Export Logs | $t(titles.app)",
|
"export-logs": "Export Logs | $t(titles.app)",
|
||||||
"jobs": "Active Jobs | $t(titles.app)",
|
"jobs": "Active Jobs | $t(titles.app)",
|
||||||
"jobs-admin": "Job {{ro_number}} - Admin | $t(titles.app)",
|
"jobs-admin": "Job {{ro_number}} - Admin | $t(titles.app)",
|
||||||
|
|||||||
@@ -322,6 +322,7 @@
|
|||||||
"view": ""
|
"view": ""
|
||||||
},
|
},
|
||||||
"shop": {
|
"shop": {
|
||||||
|
"dashboard": "",
|
||||||
"rbac": "",
|
"rbac": "",
|
||||||
"templates": "",
|
"templates": "",
|
||||||
"vendors": ""
|
"vendors": ""
|
||||||
@@ -669,6 +670,12 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"updatinglayout": ""
|
"updatinglayout": ""
|
||||||
},
|
},
|
||||||
|
"labels": {
|
||||||
|
"bodyhrs": "",
|
||||||
|
"dollarsinproduction": "",
|
||||||
|
"prodhrs": "",
|
||||||
|
"refhrs": ""
|
||||||
|
},
|
||||||
"titles": {
|
"titles": {
|
||||||
"monthlyrevenuegraph": "",
|
"monthlyrevenuegraph": "",
|
||||||
"productiondollars": "",
|
"productiondollars": "",
|
||||||
@@ -825,6 +832,7 @@
|
|||||||
"errors": "",
|
"errors": "",
|
||||||
"exceptiontitle": "",
|
"exceptiontitle": "",
|
||||||
"friday": "",
|
"friday": "",
|
||||||
|
"globalsearch": "",
|
||||||
"hours": "",
|
"hours": "",
|
||||||
"in": "en",
|
"in": "en",
|
||||||
"instanceconflictext": "",
|
"instanceconflictext": "",
|
||||||
@@ -1419,6 +1427,7 @@
|
|||||||
"courtesycars-contracts": "",
|
"courtesycars-contracts": "",
|
||||||
"courtesycars-newcontract": "",
|
"courtesycars-newcontract": "",
|
||||||
"customers": "Clientes",
|
"customers": "Clientes",
|
||||||
|
"dashboard": "",
|
||||||
"enterbills": "",
|
"enterbills": "",
|
||||||
"enterpayment": "",
|
"enterpayment": "",
|
||||||
"entertimeticket": "",
|
"entertimeticket": "",
|
||||||
@@ -2049,6 +2058,7 @@
|
|||||||
"courtesycars": "",
|
"courtesycars": "",
|
||||||
"courtesycars-detail": "",
|
"courtesycars-detail": "",
|
||||||
"courtesycars-new": "",
|
"courtesycars-new": "",
|
||||||
|
"dashboard": "",
|
||||||
"export-logs": "",
|
"export-logs": "",
|
||||||
"jobs": "",
|
"jobs": "",
|
||||||
"jobs-active": "",
|
"jobs-active": "",
|
||||||
@@ -2086,6 +2096,7 @@
|
|||||||
"courtesycars": "",
|
"courtesycars": "",
|
||||||
"courtesycars-create": "",
|
"courtesycars-create": "",
|
||||||
"courtesycars-detail": "",
|
"courtesycars-detail": "",
|
||||||
|
"dashboard": "",
|
||||||
"export-logs": "",
|
"export-logs": "",
|
||||||
"jobs": "Todos los trabajos | $t(titles.app)",
|
"jobs": "Todos los trabajos | $t(titles.app)",
|
||||||
"jobs-admin": "",
|
"jobs-admin": "",
|
||||||
|
|||||||
@@ -322,6 +322,7 @@
|
|||||||
"view": ""
|
"view": ""
|
||||||
},
|
},
|
||||||
"shop": {
|
"shop": {
|
||||||
|
"dashboard": "",
|
||||||
"rbac": "",
|
"rbac": "",
|
||||||
"templates": "",
|
"templates": "",
|
||||||
"vendors": ""
|
"vendors": ""
|
||||||
@@ -669,6 +670,12 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"updatinglayout": ""
|
"updatinglayout": ""
|
||||||
},
|
},
|
||||||
|
"labels": {
|
||||||
|
"bodyhrs": "",
|
||||||
|
"dollarsinproduction": "",
|
||||||
|
"prodhrs": "",
|
||||||
|
"refhrs": ""
|
||||||
|
},
|
||||||
"titles": {
|
"titles": {
|
||||||
"monthlyrevenuegraph": "",
|
"monthlyrevenuegraph": "",
|
||||||
"productiondollars": "",
|
"productiondollars": "",
|
||||||
@@ -825,6 +832,7 @@
|
|||||||
"errors": "",
|
"errors": "",
|
||||||
"exceptiontitle": "",
|
"exceptiontitle": "",
|
||||||
"friday": "",
|
"friday": "",
|
||||||
|
"globalsearch": "",
|
||||||
"hours": "",
|
"hours": "",
|
||||||
"in": "dans",
|
"in": "dans",
|
||||||
"instanceconflictext": "",
|
"instanceconflictext": "",
|
||||||
@@ -1419,6 +1427,7 @@
|
|||||||
"courtesycars-contracts": "",
|
"courtesycars-contracts": "",
|
||||||
"courtesycars-newcontract": "",
|
"courtesycars-newcontract": "",
|
||||||
"customers": "Les clients",
|
"customers": "Les clients",
|
||||||
|
"dashboard": "",
|
||||||
"enterbills": "",
|
"enterbills": "",
|
||||||
"enterpayment": "",
|
"enterpayment": "",
|
||||||
"entertimeticket": "",
|
"entertimeticket": "",
|
||||||
@@ -2049,6 +2058,7 @@
|
|||||||
"courtesycars": "",
|
"courtesycars": "",
|
||||||
"courtesycars-detail": "",
|
"courtesycars-detail": "",
|
||||||
"courtesycars-new": "",
|
"courtesycars-new": "",
|
||||||
|
"dashboard": "",
|
||||||
"export-logs": "",
|
"export-logs": "",
|
||||||
"jobs": "",
|
"jobs": "",
|
||||||
"jobs-active": "",
|
"jobs-active": "",
|
||||||
@@ -2086,6 +2096,7 @@
|
|||||||
"courtesycars": "",
|
"courtesycars": "",
|
||||||
"courtesycars-create": "",
|
"courtesycars-create": "",
|
||||||
"courtesycars-detail": "",
|
"courtesycars-detail": "",
|
||||||
|
"dashboard": "",
|
||||||
"export-logs": "",
|
"export-logs": "",
|
||||||
"jobs": "Tous les emplois | $t(titles.app)",
|
"jobs": "Tous les emplois | $t(titles.app)",
|
||||||
"jobs-admin": "",
|
"jobs-admin": "",
|
||||||
|
|||||||
3750
client/yarn.lock
3750
client/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
- args:
|
||||||
|
cascade: false
|
||||||
|
read_only: false
|
||||||
|
sql: ALTER TABLE ONLY "public"."users" ALTER COLUMN "dashboardlayout" SET DEFAULT
|
||||||
|
jsonb_build_array();
|
||||||
|
type: run_sql
|
||||||
|
- args:
|
||||||
|
cascade: false
|
||||||
|
read_only: false
|
||||||
|
sql: ALTER TABLE "public"."users" ALTER COLUMN "dashboardlayout" SET NOT NULL;
|
||||||
|
type: run_sql
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
- args:
|
||||||
|
cascade: false
|
||||||
|
read_only: false
|
||||||
|
sql: ALTER TABLE "public"."users" ALTER COLUMN "dashboardlayout" DROP DEFAULT;
|
||||||
|
type: run_sql
|
||||||
|
- args:
|
||||||
|
cascade: false
|
||||||
|
read_only: false
|
||||||
|
sql: ALTER TABLE "public"."users" ALTER COLUMN "dashboardlayout" DROP NOT NULL;
|
||||||
|
type: run_sql
|
||||||
@@ -365,7 +365,7 @@ function CalculateTaxesTotals(job, otherTotals) {
|
|||||||
if (!val.tax_part || (!val.part_type && IsAdditionalCost(val))) {
|
if (!val.tax_part || (!val.part_type && IsAdditionalCost(val))) {
|
||||||
additionalItemsTax = additionalItemsTax.add(
|
additionalItemsTax = additionalItemsTax.add(
|
||||||
Dinero({ amount: Math.round((val.act_price || 0) * 100) })
|
Dinero({ amount: Math.round((val.act_price || 0) * 100) })
|
||||||
.multiply(val.part_qty || 1)
|
.multiply(val.part_qty || 0)
|
||||||
.percentage(
|
.percentage(
|
||||||
((job.parts_tax_rates &&
|
((job.parts_tax_rates &&
|
||||||
job.parts_tax_rates["PAN"] &&
|
job.parts_tax_rates["PAN"] &&
|
||||||
@@ -376,7 +376,7 @@ function CalculateTaxesTotals(job, otherTotals) {
|
|||||||
} else {
|
} else {
|
||||||
statePartsTax = statePartsTax.add(
|
statePartsTax = statePartsTax.add(
|
||||||
Dinero({ amount: Math.round((val.act_price || 0) * 100) })
|
Dinero({ amount: Math.round((val.act_price || 0) * 100) })
|
||||||
.multiply(val.part_qty || 1)
|
.multiply(val.part_qty || 0)
|
||||||
.add(
|
.add(
|
||||||
Dinero({
|
Dinero({
|
||||||
amount: Math.round((val.act_price || 0) * 100),
|
amount: Math.round((val.act_price || 0) * 100),
|
||||||
|
|||||||
Reference in New Issue
Block a user