diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 81acd4f2e..ecb15e1e8 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -6963,6 +6963,53 @@ + + ttapprovals + + + approve + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + view + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + users @@ -16464,6 +16511,27 @@ + + excel + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + exceptiontitle false @@ -32429,6 +32497,27 @@ + + ttapprovals + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + vehicles false @@ -37714,6 +37803,27 @@ + + dms_posting_sheet + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + envelope_return_address false @@ -41367,6 +41477,48 @@ + + exported_gsr_by_ro + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + exported_gsr_by_ro_labor + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + gsr_by_atp false @@ -46228,6 +46380,27 @@ + + ttapprovals + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + vehicle-details false @@ -47154,6 +47327,27 @@ + + ttapprovals + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + vehicledetail false diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 1ed3f526d..d39169401 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -246,6 +246,11 @@ function Header({ {t("menus.header.timetickets")} + }> + + {t("menus.header.ttapprovals")} + + } diff --git a/client/src/components/shop-info/shop-info.rbac.component.jsx b/client/src/components/shop-info/shop-info.rbac.component.jsx index 5d7c244cc..2a32cca54 100644 --- a/client/src/components/shop-info/shop-info.rbac.component.jsx +++ b/client/src/components/shop-info/shop-info.rbac.component.jsx @@ -532,6 +532,30 @@ export function ShopInfoRbacComponent({ form, bodyshop }) { > + + + + + + - _.omit(ticket, "pay") - ), - }, - }); - if (result.errors) { - notification.open({ - type: "error", - message: t("timetickets.errors.creating", { - message: JSON.stringify(result.errors), - }), + if (true) { + const result = await insertTimeTicketApproval({ + variables: { + timeTicketInput: values.timetickets.map((ticket) => ({ + ..._.omit(ticket, "pay"), + bodyshopid: bodyshop.id, + })), + }, }); + if (result.errors) { + notification.open({ + type: "error", + message: t("timetickets.errors.creating", { + message: JSON.stringify(result.errors), + }), + }); + } else { + notification.open({ + type: "success", + message: t("timetickets.successes.created"), + }); + toggleModalVisible(); + } } else { - notification.open({ - type: "success", - message: t("timetickets.successes.created"), + const result = await insertTimeTickets({ + variables: { + timeTicketInput: values.timetickets.map((ticket) => + _.omit(ticket, "pay") + ), + }, }); - toggleModalVisible(); + if (result.errors) { + notification.open({ + type: "error", + message: t("timetickets.errors.creating", { + message: JSON.stringify(result.errors), + }), + }); + } else { + notification.open({ + type: "success", + message: t("timetickets.successes.created"), + }); + toggleModalVisible(); + } } } catch (error) { } finally { diff --git a/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx b/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx new file mode 100644 index 000000000..9c1befc79 --- /dev/null +++ b/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx @@ -0,0 +1,243 @@ +import { EditFilled } from "@ant-design/icons"; +import { Button, Card, Space, Table, Tag } from "antd"; +import Dinero from "dinero.js"; +import moment from "moment"; +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { Link, useHistory, useLocation } from "react-router-dom"; +import { createStructuredSelector } from "reselect"; +import { setModalContext } from "../../redux/modals/modals.actions"; +import { + selectAuthLevel, + selectBodyshop, +} from "../../redux/user/user.selectors"; +import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter"; +import { onlyUnique } from "../../utils/arrayHelper"; +import { alphaSort, dateSort } from "../../utils/sorters"; +import RbacWrapper, { + HasRbacAccess, +} from "../rbac-wrapper/rbac-wrapper.component"; +import TimeTicketEnterButton from "../time-ticket-enter-button/time-ticket-enter-button.component"; +import queryString from "query-string"; +import TtApproveButtonComponent from "../tt-approve-button/tt-approve-button.component"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + authLevel: selectAuthLevel, +}); + +const mapDispatchToProps = (dispatch) => ({ + setTimeTicketTaskContext: (context) => + dispatch(setModalContext({ context: context, modal: "timeTicketTask" })), +}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(TtApprovalsListComponent); + +export function TtApprovalsListComponent({ + bodyshop, + setTimeTicketTaskContext, + authLevel, + disabled, + loading, + tt_approval_queue, + total, + refetch, + techConsole, + jobId, + extra, +}) { + const [state, setState] = useState({ + sortedInfo: {}, + filteredInfo: { text: "" }, + }); + + const { t } = useTranslation(); + const history = useHistory(); + const search = queryString.parse(useLocation().search); + const { page } = search; + const [selectedTickets, setSelectedTickets] = useState([]); + + const columns = [ + { + title: t("timetickets.fields.date"), + dataIndex: "date", + key: "date", + sorter: (a, b) => dateSort(a.date, b.date), + sortOrder: + state.sortedInfo.columnKey === "date" && state.sortedInfo.order, + render: (text, record) => {record.date}, + }, + { + title: t("timetickets.fields.employee"), + dataIndex: "employeeid", + key: "employeeid", + sorter: (a, b) => alphaSort(a.employee.last_name, b.employee.last_name), + sortOrder: + state.sortedInfo.columnKey === "employee" && state.sortedInfo.order, + render: (text, record) => + `${record.employee.first_name} ${record.employee.last_name}`, + filters: + tt_approval_queue + .map((l) => l.employeeid) + .filter(onlyUnique) + .map((s) => { + return { + text: (() => { + const emp = bodyshop.employees.find((e) => e.id === s); + + return `${emp?.first_name} ${emp?.last_name}`; + })(), // + value: [s], + }; + }) || [], + onFilter: (value, record) => value.includes(record.employeeid), + }, + { + title: t("timetickets.fields.cost_center"), + dataIndex: "cost_center", + key: "cost_center", + sorter: (a, b) => alphaSort(a.cost_center, b.cost_center), + render: (text, record) => + record.cost_center === "timetickets.labels.shift" + ? t(record.cost_center) + : record.cost_center, + sortOrder: + state.sortedInfo.columnKey === "cost_center" && state.sortedInfo.order, + filters: + tt_approval_queue + .map((l) => l.cost_center) + .filter(onlyUnique) + .map((s) => { + return { + text: s === "timetickets.labels.shift" ? t(s) : s, //|| "No Status*", + value: [s], + }; + }) || [], + onFilter: (value, record) => value.includes(record.cost_center), + }, + { + title: t("jobs.fields.ro_number"), + dataIndex: "ro_number", + key: "ro_number", + sorter: (a, b) => + alphaSort(a.job && a.job.ro_number, b.job && b.job.ro_number), + sortOrder: + state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order, + render: (text, record) => + record.job && ( + + + {record.job.ro_number || "N/A"} + {record.job.status} + + + ), + }, + { + title: t("timetickets.fields.productivehrs"), + dataIndex: "productivehrs", + key: "productivehrs", + sorter: (a, b) => a.productivehrs - b.productivehrs, + sortOrder: + state.sortedInfo.columnKey === "productivehrs" && + state.sortedInfo.order, + }, + { + title: t("timetickets.fields.actualhrs"), + dataIndex: "actualhrs", + key: "actualhrs", + sorter: (a, b) => a.actualhrs - b.actualhrs, + sortOrder: + state.sortedInfo.columnKey === "actualhrs" && state.sortedInfo.order, + }, + { + title: t("timetickets.fields.memo"), + dataIndex: "memo", + key: "memo", + sorter: (a, b) => alphaSort(a.memo, b.memo), + sortOrder: + state.sortedInfo.columnKey === "memo" && state.sortedInfo.order, + render: (text, record) => + record.clockon || record.clockoff ? t(record.memo) : record.memo, + }, + { + title: t("timetickets.fields.clockon"), + dataIndex: "clockon", + key: "clockon", + + render: (text, record) => ( + {record.clockon} + ), + }, + { + title: "Pay", + dataIndex: "pay", + key: "pay", + render: (text, record) => + Dinero({ amount: Math.round(record.rate * 100) }) + .multiply(record.flat_rate ? record.productivehrs : record.actualhrs) + .toFormat("$0.00"), + }, + ]; + + const handleTableChange = (pagination, filters, sorter) => { + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); + search.page = pagination.current; + if (sorter && sorter.column && sorter.column.sortObject) { + search.searchObj = JSON.stringify(sorter.column.sortObject(sorter.order)); + } else { + delete search.searchObj; + search.sortcolumn = sorter.order ? sorter.columnKey : null; + search.sortorder = sorter.order; + } + + search.sort = JSON.stringify({ [sorter.columnKey]: sorter.order }); + history.push({ search: queryString.stringify(search) }); + }; + + return ( + + {extra} + + + } + > + + setSelectedTickets(selectedRows.map((i) => i.id)), + onSelect: (record, selected, selectedRows, nativeEvent) => { + setSelectedTickets(selectedRows.map((i) => i.id)); + }, + selectedRowKeys: selectedTickets, + type: "checkbox", + }} + /> + + ); +} diff --git a/client/src/components/tt-approvals-list/tt-approvals-list.container.jsx b/client/src/components/tt-approvals-list/tt-approvals-list.container.jsx new file mode 100644 index 000000000..0ef860fe5 --- /dev/null +++ b/client/src/components/tt-approvals-list/tt-approvals-list.container.jsx @@ -0,0 +1,66 @@ +import { useQuery } from "@apollo/client"; +import React from "react"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import AlertComponent from "../alert/alert.component"; +import { QUERY_TIME_TICKETS_IN_RANGE } from "../../graphql/timetickets.queries"; +import { + setBreadcrumbs, + setSelectedHeader, +} from "../../redux/application/application.actions"; +import TtApprovalsListComponent from "./tt-approvals-list.component"; +import { useLocation } from "react-router-dom"; +import queryString from "query-string"; +import { QUERY_ALL_TT_APPROVALS_PAGINATED } from "../../graphql/tt-approvals.queries"; + +const mapStateToProps = createStructuredSelector({}); + +const mapDispatchToProps = (dispatch) => ({ + setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), + setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), +}); + +export function TimeTicketsContainer({ + bodyshop, + setBreadcrumbs, + setSelectedHeader, +}) { + const searchParams = queryString.parse(useLocation().search); + const { page, sortcolumn, sortorder, search, searchObj } = searchParams; + + const { loading, error, data } = useQuery(QUERY_ALL_TT_APPROVALS_PAGINATED, { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + variables: { + search: search || "", + offset: page ? (page - 1) * 25 : 0, + limit: 25, + order: [ + searchObj + ? JSON.parse(searchObj) + : { + [sortcolumn || "date"]: sortorder + ? sortorder === "descend" + ? "desc" + : "asc" + : "desc", + }, + ], + }, + }); + + if (error) return ; + + return ( + + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TimeTicketsContainer); diff --git a/client/src/components/tt-approve-button/tt-approve-button.component.jsx b/client/src/components/tt-approve-button/tt-approve-button.component.jsx new file mode 100644 index 000000000..a8ab89aa9 --- /dev/null +++ b/client/src/components/tt-approve-button/tt-approve-button.component.jsx @@ -0,0 +1,68 @@ +import { useApolloClient } from "@apollo/client"; +import { Button } from "antd"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { + QUERY_TT_APPROVALS_BY_IDS, + UPDATE_TT_BY_APPROVAL, +} from "../../graphql/tt-approvals.queries"; +import { + selectBodyshop, + selectCurrentUser, +} from "../../redux/user/user.selectors"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + currentUser: selectCurrentUser, +}); + +export function TtApproveButton({ + bodyshop, + currentUser, + selectedTickets, + disabled, + loadingCallback, + completedCallback, + refetch, +}) { + const { t } = useTranslation(); + const client = useApolloClient(); + + const [loading, setLoading] = useState(false); + + const handleQbxml = async () => { + const { data } = await client.query({ + query: QUERY_TT_APPROVALS_BY_IDS, + variables: { ids: selectedTickets }, + }); + + const { data: insertData } = await client.query({ + query: UPDATE_TT_BY_APPROVAL, + variables: { + ttApprovalUpdates: data.map((tta) => ({ + _set: {}, + where: { id: { _eq: tta.id } }, + })), + }, + }); + + // if (!!completedCallback) completedCallback([]); + // if (!!loadingCallback) loadingCallback(false); + + // setLoading(false); + }; + + return ( + + ); +} + +export default connect(mapStateToProps, null)(TtApproveButton); + +const generateGqlUpdate = (ttapproval) => { + return; +}; diff --git a/client/src/graphql/tt-approvals.queries.js b/client/src/graphql/tt-approvals.queries.js new file mode 100644 index 000000000..48540d935 --- /dev/null +++ b/client/src/graphql/tt-approvals.queries.js @@ -0,0 +1,93 @@ +import { gql } from "@apollo/client"; + +export const QUERY_ALL_TT_APPROVALS_PAGINATED = gql` + query QUERY_ALL_TT_APPROVALS_PAGINATED( + $offset: Int + $limit: Int + $order: [tt_approval_queue_order_by!]! + ) { + tt_approval_queue(offset: $offset, limit: $limit, order_by: $order) { + id + jobid + bodyshopid + employeeid + employee { + first_name + last_name + id + } + job { + ro_number + status + id + } + employeeid + actualhrs + productivehrs + ciecacode + cost_center + date + rate + } + tt_approval_queue_aggregate { + aggregate { + count(distinct: true) + } + } + } +`; + +export const INSERT_NEW_TT_APPROVALS = gql` + mutation INSERT_NEW_TT_APPROVALS( + $timeTicketInput: [tt_approval_queue_insert_input!]! + ) { + insert_tt_approval_queue(objects: $timeTicketInput) { + returning { + id + employeeid + productivehrs + actualhrs + ciecacode + date + memo + flat_rate + } + } + } +`; + +export const QUERY_TT_APPROVALS_BY_IDS = gql` + query QUERY_TT_APPROVALS_BY_IDS($ids: [uuid!]!) { + tt_approval_queue(where: { id: { _in: $ids } }) { + id + productivehrs + actualhrs + rate + memo + jobid + flat_rate + employeeid + date + ciecacode + bodyshopid + cost_center + } + } +`; + +export const UPDATE_TT_BY_APPROVAL = gql` + mutation UPDATE_TT_BY_APPROVAL( + $ttApprovalUpdates: [tt_approval_queue_updates!]! + ) { + update_tt_approval_queue_many(updates: $ttApprovalUpdates) { + returning { + id + approved_at + approved_by + timeticket { + id + } + } + } + } +`; diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index d117aaeec..2f8b8ae37 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -170,6 +170,9 @@ const Dms = lazy(() => import("../dms/dms.container")); const DmsPayables = lazy(() => import("../dms-payables/dms-payables.container") ); +const TtApprovals = lazy(() => + import("../tt-approvals/tt-approvals.page.container") +); const { Content, Footer } = Layout; @@ -370,6 +373,7 @@ export function Manage({ match, conflict, bodyshop }) { path={`${match.path}/accounting/exportlogs`} component={ExportLogs} /> + diff --git a/client/src/pages/tt-approvals/tt-approvals.page.container.jsx b/client/src/pages/tt-approvals/tt-approvals.page.container.jsx new file mode 100644 index 000000000..1d8b689ef --- /dev/null +++ b/client/src/pages/tt-approvals/tt-approvals.page.container.jsx @@ -0,0 +1,42 @@ +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; +import TtApprovalsList from "../../components/tt-approvals-list/tt-approvals-list.container"; +import { + setBreadcrumbs, + setSelectedHeader, +} from "../../redux/application/application.actions"; +import { selectBodyshop } from "../../redux/user/user.selectors"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, +}); + +const mapDispatchToProps = (dispatch) => ({ + setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), + setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), +}); + +export function TtApprovalsPage({ setBreadcrumbs, setSelectedHeader }) { + const { t } = useTranslation(); + + useEffect(() => { + document.title = t("titles.ttapprovals"); + setSelectedHeader("ttapprovals"); + setBreadcrumbs([ + { + link: "/manage/ttapprovals", + label: t("titles.bc.ttapprovals"), + }, + ]); + }, [t, setBreadcrumbs, setSelectedHeader]); + + return ( + + + + ); +} +export default connect(mapStateToProps, mapDispatchToProps)(TtApprovalsPage); diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 9fe5d867e..400a413e0 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -439,6 +439,10 @@ "list": "Time Tickets -> List", "shiftedit": "Time Tickets -> Shift Edit" }, + "ttapprovals": { + "approve": "Time Ticket Approval -> Approve", + "view": "Time Ticket Approval -> View" + }, "users": { "editaccess": "Users -> Edit access" } @@ -1902,6 +1906,7 @@ "shop_vendors": "Vendors", "temporarydocs": "Temporary Documents", "timetickets": "Time Tickets", + "ttapprovals": "Time Ticket Approvals", "vehicles": "Vehicles" }, "jobsactions": { @@ -2733,6 +2738,7 @@ "shop-vendors": "Vendors", "temporarydocs": "Temporary Documents", "timetickets": "Time Tickets", + "ttapprovals": "Time Ticket Approvals", "vehicle-details": "Vehicle: {{vehicle}}", "vehicles": "Vehicles" }, @@ -2778,6 +2784,7 @@ "shop_vendors": "Vendors | $t(titles.app)", "temporarydocs": "Temporary Documents | $t(titles.app)", "timetickets": "Time Tickets | $t(titles.app)", + "ttapprovals": "Time Ticket Approvals | $t(titles.app)", "vehicledetail": "Vehicle Details {{vehicle}} | $t(titles.app)", "vehicles": "All Vehicles | $t(titles.app)" }, diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 1b4dda6b8..9184f2237 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -439,6 +439,10 @@ "list": "", "shiftedit": "" }, + "ttapprovals": { + "approve": "", + "view": "" + }, "users": { "editaccess": "" } @@ -1902,6 +1906,7 @@ "shop_vendors": "Vendedores", "temporarydocs": "", "timetickets": "", + "ttapprovals": "", "vehicles": "Vehículos" }, "jobsactions": { @@ -2733,6 +2738,7 @@ "shop-vendors": "", "temporarydocs": "", "timetickets": "", + "ttapprovals": "", "vehicle-details": "", "vehicles": "" }, @@ -2778,6 +2784,7 @@ "shop_vendors": "Vendedores | $t(titles.app)", "temporarydocs": "", "timetickets": "", + "ttapprovals": "", "vehicledetail": "Detalles del vehículo {{vehicle}} | $t(titles.app)", "vehicles": "Todos los vehiculos | $t(titles.app)" }, diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 35bba987c..5481a9dc5 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -439,6 +439,10 @@ "list": "", "shiftedit": "" }, + "ttapprovals": { + "approve": "", + "view": "" + }, "users": { "editaccess": "" } @@ -1902,6 +1906,7 @@ "shop_vendors": "Vendeurs", "temporarydocs": "", "timetickets": "", + "ttapprovals": "", "vehicles": "Véhicules" }, "jobsactions": { @@ -2733,6 +2738,7 @@ "shop-vendors": "", "temporarydocs": "", "timetickets": "", + "ttapprovals": "", "vehicle-details": "", "vehicles": "" }, @@ -2778,6 +2784,7 @@ "shop_vendors": "Vendeurs | $t(titles.app)", "temporarydocs": "", "timetickets": "", + "ttapprovals": "", "vehicledetail": "Détails du véhicule {{vehicle} | $t(titles.app)", "vehicles": "Tous les véhicules | $t(titles.app)" },