BOD-26 Added totals calculation for job reconciliation.

This commit is contained in:
Patrick Fic
2020-05-14 11:05:55 -07:00
parent 0473421c6c
commit cc47fd698a
14 changed files with 502 additions and 19 deletions

View File

@@ -1,4 +1,4 @@
<babeledit_project be_version="2.6.1" version="1.2">
<babeledit_project version="1.2" be_version="2.6.1">
<!--
BabelEdit project file
@@ -5242,6 +5242,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>quantity</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>retail</name>
<definition_loaded>false</definition_loaded>
@@ -6661,6 +6682,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>reconcile</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>schedule</name>
<definition_loaded>false</definition_loaded>
@@ -9836,6 +9878,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>reconciliationheader</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>shop_mat</name>
<definition_loaded>false</definition_loaded>

View File

@@ -132,7 +132,7 @@ export function JobLinesComponent({
key: "total",
sorter: (a, b) => a.act_price * a.part_qty - b.act_price * b.part_qty,
sortOrder:
state.sortedInfo.columnKey === "act_price" && state.sortedInfo.order,
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
ellipsis: true,
render: (text, record) => (

View File

@@ -0,0 +1,113 @@
import { Button, Table, Statistic, Checkbox } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort } from "../../utils/sorters";
import Dinero from "dinero.js";
export default function JobReconciliationInvoiceTable({
invoiceLineState,
invoiceLineData,
}) {
const { t } = useTranslation();
const [state, setState] = useState({
sortedInfo: {},
});
const [selectedLines, setSelectedLines] = invoiceLineState;
const [total, setTotal] = useState(Dinero({ amount: 0 }).toFormat());
const columns = [
{
title: t("invoicelines.fields.line_desc"),
dataIndex: "line_desc",
key: "line_desc",
sorter: (a, b) => alphaSort(a.line_desc, b.line_desc),
sortOrder:
state.sortedInfo.columnKey === "line_desc" && state.sortedInfo.order,
},
{
title: t("invoicelines.fields.retail"),
dataIndex: "actual_price",
key: "actual_price",
sorter: (a, b) => a.actual_price - b.actual_price,
sortOrder:
state.sortedInfo.columnKey === "actual_price" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.actual_price}</CurrencyFormatter>
),
},
{
title: t("invoicelines.fields.actual_cost"),
dataIndex: "actual_cost",
key: "actual_cost",
sorter: (a, b) => a.actual_cost - b.actual_cost,
sortOrder:
state.sortedInfo.columnKey === "actual_cost" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.actual_cost}</CurrencyFormatter>
),
},
{
title: t("invoicelines.fields.quantity"),
dataIndex: "quantity",
key: "quantity",
sorter: (a, b) => a.quantity - b.quantity,
sortOrder:
state.sortedInfo.columnKey === "quantity" && state.sortedInfo.order,
},
{
title: t("invoices.fields.is_credit_memo"),
dataIndex: "is_credit_memo",
key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
sortOrder:
state.sortedInfo.columnKey === "is_credit_memo" &&
state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.is_credit_memo} />,
},
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const handleOnRowClick = (selectedRecordKeys, selectedRecords) => {
setSelectedLines(selectedRecordKeys);
calculateTotal(selectedRecords);
};
const calculateTotal = (selectedRecords) => {
let total = Dinero({ amount: 0 });
selectedRecords.forEach(
(record) =>
(total = total.add(
Dinero({
amount:
record.actual_price * 100 * (record.is_credit_memo ? -1 : 1),
}).multiply(record.quantity)
))
);
setTotal(total.toFormat());
};
return (
<div>
<Table
size='small'
title={() => <div></div>}
pagination={{ position: "top", defaultPageSize: 25 }}
columns={columns}
rowKey='id'
dataSource={invoiceLineData}
onChange={handleTableChange}
rowSelection={{
onChange: handleOnRowClick,
selectedRowKeys: selectedLines,
}}
/>
<Statistic value={total} title='total' />
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Col, Row } from "antd";
import React, { useState } from "react";
import JobReconciliationInvoicesTable from "../job-reconciliation-invoices-table/job-reconciliation-invoices-table.component";
import JobReconciliationPartsTable from "../job-reconciliation-parts-table/job-reconciliation-parts-table.component";
export default function JobReconciliationModalComponent({ job, invoices }) {
const jobLineState = useState([]);
const invoiceLineState = useState([]);
const invoiceLineData =
invoices
.map((i) =>
i.invoicelines.map((il) => {
return { ...il, is_credit_memo: i.is_credit_memo };
})
)
.flat() || [];
console.log(
"JobReconciliationModalComponent -> invoiceLineData",
invoiceLineData
);
const jobLineData = job.joblines.filter((j) => j.part_type !== null);
return (
<div>
<Row>
<Col span={12}>
<JobReconciliationPartsTable
jobLineData={jobLineData}
jobLineState={jobLineState}
/>
</Col>
<Col span={12}>
<JobReconciliationInvoicesTable
invoiceLineData={invoiceLineData}
invoiceLineState={invoiceLineState}
/>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Modal } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectReconciliation } from "../../redux/modals/modals.selectors";
import {
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import JobReconciliationModalComponent from "./job-reconciliation-modal.component";
const mapStateToProps = createStructuredSelector({
reconciliationModal: selectReconciliation,
bodyshop: selectBodyshop,
currentUser: selectCurrentUser,
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("reconciliation")),
});
function InvoiceEnterModalContainer({
reconciliationModal,
toggleModalVisible,
bodyshop,
currentUser,
}) {
const { t } = useTranslation();
const { actions, context, visible } = reconciliationModal;
const { job, invoices } = context;
const handleCancel = () => {
toggleModalVisible();
};
return (
<Modal
title={t("jobs.labels.reconciliationheader")}
width={"90%"}
visible={visible}
okText={t("general.actions.save")}
onOk={handleCancel}
onCancel={handleCancel}
destroyOnClose>
<JobReconciliationModalComponent job={job} invoices={invoices} />
</Modal>
);
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(InvoiceEnterModalContainer);

View File

@@ -0,0 +1,157 @@
import { Button, Table, Statistic } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort } from "../../utils/sorters";
import Dinero from "dinero.js";
export default function JobReconcilitionPartsTable({
jobLineState,
jobLineData,
}) {
const { t } = useTranslation();
const [state, setState] = useState({
sortedInfo: {},
});
const [selectedLines, setSelectedLines] = jobLineState;
const [total, setTotal] = useState(Dinero({ amount: 0 }).toFormat());
const columns = [
// {
// title: t("joblines.fields.line_no"),
// dataIndex: "line_no",
// key: "line_no",
// sorter: (a, b) => a.line_no - b.line_no,
// sortOrder:
// state.sortedInfo.columnKey === "line_no" && state.sortedInfo.order,
// //ellipsis: true,
// editable: true,
// width: 75,
// },
{
title: t("joblines.fields.line_desc"),
dataIndex: "line_desc",
key: "line_desc",
sorter: (a, b) => alphaSort(a.line_desc, b.line_desc),
sortOrder:
state.sortedInfo.columnKey === "line_desc" && state.sortedInfo.order,
},
{
title: t("joblines.fields.oem_partno"),
dataIndex: "oem_partno",
key: "oem_partno",
sorter: (a, b) =>
alphaSort(
a.oem_partno ? a.oem_partno : a.op_code_desc,
b.oem_partno ? b.oem_partno : b.op_code_desc
),
sortOrder:
state.sortedInfo.columnKey === "oem_partno" && state.sortedInfo.order,
render: (text, record) => (
<span>
{record.oem_partno ? record.oem_partno : record.op_code_desc}
</span>
),
},
{
title: t("joblines.fields.part_type"),
dataIndex: "part_type",
key: "part_type",
sorter: (a, b) => alphaSort(a.part_type, b.part_type),
sortOrder:
state.sortedInfo.columnKey === "part_type" && state.sortedInfo.order,
},
{
title: t("joblines.fields.act_price"),
dataIndex: "act_price",
key: "act_price",
sorter: (a, b) => a.act_price - b.act_price,
sortOrder:
state.sortedInfo.columnKey === "act_price" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.act_price}</CurrencyFormatter>
),
},
{
title: t("joblines.fields.part_qty"),
dataIndex: "part_qty",
key: "part_qty",
},
{
title: t("joblines.fields.total"),
dataIndex: "total",
key: "total",
sorter: (a, b) => a.act_price * a.part_qty - b.act_price * b.part_qty,
sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>
{record.act_price * record.part_qty}
</CurrencyFormatter>
),
},
{
title: t("joblines.fields.mod_lb_hrs"),
dataIndex: "mod_lb_hrs",
key: "mod_lb_hrs",
sorter: (a, b) => a.mod_lb_hrs - b.mod_lb_hrs,
sortOrder:
state.sortedInfo.columnKey === "mod_lb_hrs" && state.sortedInfo.order,
},
{
title: t("joblines.fields.status"),
dataIndex: "status",
key: "status",
sorter: (a, b) => alphaSort(a.status, b.status),
sortOrder:
state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
},
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const handleOnRowClick = (selectedRecordKeys, selectedRecords) => {
setSelectedLines(selectedRecordKeys);
calculateTotal(selectedRecords);
};
const calculateTotal = (selectedRecords) => {
let total = Dinero({ amount: 0 });
selectedRecords.forEach(
(record) =>
(total = total.add(
Dinero({ amount: record.act_price * 100 }).multiply(record.part_qty)
))
);
setTotal(total.toFormat());
};
return (
<div>
<Table
size='small'
title={() => (
<div>
</div>
)}
pagination={{ position: "top", defaultPageSize: 25 }}
columns={columns}
rowKey='id'
dataSource={jobLineData}
onChange={handleTableChange}
rowSelection={{
onChange: handleOnRowClick,
selectedRowKeys: selectedLines,
}}
/>
<Statistic value={total} title='total' />
</div>
);
}

View File

@@ -5,19 +5,23 @@ import { setModalContext } from "../../redux/modals/modals.actions";
import AlertComponent from "../alert/alert.component";
import InvoicesListTableComponent from "../invoices-list-table/invoices-list-table.component";
import JobInvoicesTotalsComponent from "../job-invoices-total/job-invoices-total.component";
import { useTranslation } from "react-i18next";
const mapDispatchToProps = (dispatch) => ({
setInvoiceEnterContext: (context) =>
dispatch(setModalContext({ context: context, modal: "invoiceEnter" })),
setReconciliationContext: (context) =>
dispatch(setModalContext({ context: context, modal: "reconciliation" })),
});
export function JobsDetailPliComponent({
setInvoiceEnterContext,
setReconciliationContext,
job,
invoicesQuery,
handleOnRowClick,
selectedInvoice,
}) {
const { t } = useTranslation();
return (
<div>
<Button
@@ -29,8 +33,21 @@ export function JobsDetailPliComponent({
},
});
}}>
Enter Invoice
{t("jobs.actions.postInvoices")}
</Button>
<Button
onClick={() => {
setReconciliationContext({
actions: { refetch: invoicesQuery.refetch },
context: {
job,
invoices: invoicesQuery.data && invoicesQuery.data.invoices,
},
});
}}>
{t("jobs.actions.reconcile")}
</Button>
{invoicesQuery.error ? (
<AlertComponent message={invoicesQuery.error.message} type='error' />
) : null}

View File

@@ -52,6 +52,7 @@ export const QUERY_INVOICES_BY_JOBID = gql`
federal_tax_rate
state_tax_rate
local_tax_rate
is_credit_memo
invoicelines {
actual_price
quantity

View File

@@ -1,10 +1,22 @@
import Icon, { BarsOutlined, CalendarFilled, DollarCircleOutlined, FileImageFilled, ToolFilled } from "@ant-design/icons";
import Icon, {
BarsOutlined,
CalendarFilled,
DollarCircleOutlined,
FileImageFilled,
ToolFilled,
} from "@ant-design/icons";
import { Form, notification, Tabs } from "antd";
import moment from "moment";
import queryString from "query-string";
import React, { lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { FaHardHat, FaHistory, FaInfo, FaRegStickyNote, FaShieldAlt } from "react-icons/fa";
import {
FaHardHat,
FaHistory,
FaInfo,
FaRegStickyNote,
FaShieldAlt,
} from "react-icons/fa";
import { connect } from "react-redux";
import { useHistory, useLocation } from "react-router-dom";
import { createStructuredSelector } from "reselect";
@@ -50,7 +62,6 @@ const JobLineUpsertModalContainer = lazy(() =>
"../../components/job-lines-upsert-modal/job-lines-upsert-modal.container"
)
);
const JobsDetailPliContainer = lazy(() =>
import("../../components/jobs-detail-pli/jobs-detail-pli.container")
);
@@ -60,6 +71,11 @@ const JobsDetailAuditContainer = lazy(() =>
const JobsDetailLaborContainer = lazy(() =>
import("../../components/jobs-detail-labor/jobs-detail-labor.container")
);
const JobReconciliationModal = lazy(() =>
import(
"../../components/job-reconciliation-modal/job-reconciliation.modal.container"
)
);
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -114,6 +130,7 @@ export function JobsDetailPage({
<Suspense
fallback={<LoadingSpinner message={t("general.labels.loadingapp")} />}>
<ScheduleJobModalContainer />
<JobReconciliationModal />
<JobLineUpsertModalContainer />
<Form
form={form}

View File

@@ -16,7 +16,8 @@ const INITIAL_STATE = {
schedule: { ...baseModal },
partsOrder: { ...baseModal },
timeTicket: { ...baseModal },
printCenter: {...baseModal}
printCenter: { ...baseModal },
reconciliation: { ...baseModal },
};
const modalsReducer = (state = INITIAL_STATE, action) => {

View File

@@ -1,41 +1,48 @@
import { createSelector } from "reselect";
const selectModals = state => state.modals;
const selectModals = (state) => state.modals;
export const selectJobLineEditModal = createSelector(
[selectModals],
modals => modals.jobLineEdit
(modals) => modals.jobLineEdit
);
export const selectInvoiceEnterModal = createSelector(
[selectModals],
modals => modals.invoiceEnter
(modals) => modals.invoiceEnter
);
export const selectCourtesyCarReturn = createSelector(
[selectModals],
modals => modals.courtesyCarReturn
(modals) => modals.courtesyCarReturn
);
export const selectNoteUpsert = createSelector(
[selectModals],
modals => modals.noteUpsert
(modals) => modals.noteUpsert
);
export const selectSchedule = createSelector(
[selectModals],
modals => modals.schedule
(modals) => modals.schedule
);
export const selectPartsOrder = createSelector(
[selectModals],
modals => modals.partsOrder
(modals) => modals.partsOrder
);
export const selectTimeTicket = createSelector(
[selectModals],
modals => modals.timeTicket
);export const selectPrintCenter = createSelector(
(modals) => modals.timeTicket
);
export const selectPrintCenter = createSelector(
[selectModals],
modals => modals.printCenter
);
(modals) => modals.printCenter
);
export const selectReconciliation = createSelector(
[selectModals],
(modals) => modals.reconciliation
);

View File

@@ -365,6 +365,7 @@
"jobline": "Job Line",
"line_desc": "Invoice Line Description",
"local_tax_applicable": "Loc. Tax?",
"quantity": "Quantity",
"retail": "Retail",
"state_tax_applicable": "St. Tax?"
},
@@ -459,6 +460,7 @@
"manualnew": "Create New Job Manually",
"postInvoices": "Post Invoices",
"printCenter": "Print Center",
"reconcile": "Reconcile",
"schedule": "Schedule"
},
"errors": {
@@ -619,6 +621,7 @@
"partstotal": "Parts Total",
"rates": "Rates",
"rates_subtotal": "Rates Subtotal",
"reconciliationheader": "Parts & Sublet Reconciliation",
"shop_mat": "Shop Materials",
"state_tax_amt": "State/Provincial Taxes",
"subletstotal": "Sublets Total",

View File

@@ -365,6 +365,7 @@
"jobline": "",
"line_desc": "",
"local_tax_applicable": "",
"quantity": "",
"retail": "",
"state_tax_applicable": ""
},
@@ -459,6 +460,7 @@
"manualnew": "",
"postInvoices": "Contabilizar facturas",
"printCenter": "Centro de impresión",
"reconcile": "",
"schedule": "Programar"
},
"errors": {
@@ -619,6 +621,7 @@
"partstotal": "",
"rates": "Tarifas",
"rates_subtotal": "",
"reconciliationheader": "",
"shop_mat": "",
"state_tax_amt": "",
"subletstotal": "",

View File

@@ -365,6 +365,7 @@
"jobline": "",
"line_desc": "",
"local_tax_applicable": "",
"quantity": "",
"retail": "",
"state_tax_applicable": ""
},
@@ -459,6 +460,7 @@
"manualnew": "",
"postInvoices": "Poster des factures",
"printCenter": "Centre d'impression",
"reconcile": "",
"schedule": "Programme"
},
"errors": {
@@ -619,6 +621,7 @@
"partstotal": "",
"rates": "Les taux",
"rates_subtotal": "",
"reconciliationheader": "",
"shop_mat": "",
"state_tax_amt": "",
"subletstotal": "",