MAJOR CHANGE: Renamed invoices to bills BOD-410
This commit is contained in:
278
client/src/components/bill-form/bill-form.component.jsx
Normal file
278
client/src/components/bill-form/bill-form.component.jsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Switch,
|
||||
Typography,
|
||||
Upload,
|
||||
} from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import FormDatePicker from "../form-date-picker/form-date-picker.component";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
|
||||
import BillFormLines from "./bill-form.lines.component";
|
||||
import { CalculateBillTotal } from "./bill-form.totals.utility";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({});
|
||||
|
||||
export function BillFormComponent({
|
||||
bodyshop,
|
||||
form,
|
||||
vendorAutoCompleteOptions,
|
||||
lineData,
|
||||
responsibilityCenters,
|
||||
loadLines,
|
||||
billEdit,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [discount, setDiscount] = useState(0);
|
||||
|
||||
const handleVendorSelect = (props, opt) => {
|
||||
setDiscount(opt.discount);
|
||||
};
|
||||
|
||||
//TODO: Test this further. Required to set discount when viewing an invoice.
|
||||
useEffect(() => {
|
||||
if (form.getFieldValue("vendorid") && vendorAutoCompleteOptions) {
|
||||
const vendorId = form.getFieldValue("vendorid");
|
||||
const matchingVendors = vendorAutoCompleteOptions.filter(
|
||||
(v) => v.id === vendorId
|
||||
);
|
||||
if (matchingVendors.length === 1) {
|
||||
setDiscount(matchingVendors[0].discount);
|
||||
}
|
||||
}
|
||||
if (form.getFieldValue("jobid")) {
|
||||
loadLines({ variables: { id: form.getFieldValue("jobid") } });
|
||||
}
|
||||
}, [form, setDiscount, vendorAutoCompleteOptions, loadLines]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LayoutFormRow>
|
||||
<Form.Item
|
||||
name="jobid"
|
||||
label={t("bills.fields.ro_number")}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<JobSearchSelect
|
||||
disabled={billEdit}
|
||||
onBlur={() => {
|
||||
if (form.getFieldValue("jobid") !== null) {
|
||||
loadLines({ variables: { id: form.getFieldValue("jobid") } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bills.fields.vendor")}
|
||||
name="vendorid"
|
||||
style={{ display: billEdit ? "none" : null }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<VendorSearchSelect
|
||||
options={vendorAutoCompleteOptions}
|
||||
onSelect={handleVendorSelect}
|
||||
/>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
|
||||
<LayoutFormRow>
|
||||
<Form.Item
|
||||
label={t("bills.fields.invoice_number")}
|
||||
name="invoice_number"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bills.fields.date")}
|
||||
name="date"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FormDatePicker />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bills.fields.is_credit_memo")}
|
||||
name="is_credit_memo"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bills.fields.total")}
|
||||
name="total"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<CurrencyInput min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bills.fields.federal_tax_rate")}
|
||||
name="federal_tax_rate"
|
||||
>
|
||||
<CurrencyInput min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bills.fields.state_tax_rate")}
|
||||
name="state_tax_rate"
|
||||
>
|
||||
<CurrencyInput min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bills.fields.local_tax_rate")}
|
||||
name="local_tax_rate"
|
||||
>
|
||||
<CurrencyInput min={0} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("bills.fields.allpartslocation")} name="location">
|
||||
<Select style={{ width: "10rem" }}>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<Typography.Title level={4}>
|
||||
{t("bills.labels.bill_lines")}
|
||||
</Typography.Title>
|
||||
<BillFormLines
|
||||
lineData={lineData}
|
||||
discount={discount}
|
||||
form={form}
|
||||
responsibilityCenters={responsibilityCenters}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
name="upload"
|
||||
label="Upload"
|
||||
style={{ display: billEdit ? "none" : null }}
|
||||
valuePropName="fileList"
|
||||
getValueFromEvent={(e) => {
|
||||
if (Array.isArray(e)) {
|
||||
return e;
|
||||
}
|
||||
return e && e.fileList;
|
||||
}}
|
||||
>
|
||||
<Upload name="logo" beforeUpload={() => false} listType="picture">
|
||||
<Button>Click to upload</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
const values = form.getFieldsValue([
|
||||
"billlines",
|
||||
"total",
|
||||
"federal_tax_rate",
|
||||
"state_tax_rate",
|
||||
"local_tax_rate",
|
||||
]);
|
||||
let totals;
|
||||
if (
|
||||
!!values.total &&
|
||||
!!values.billlines &&
|
||||
values.billlines.length > 0
|
||||
)
|
||||
totals = CalculateBillTotal(values);
|
||||
if (!!totals)
|
||||
return (
|
||||
<div>
|
||||
<Space>
|
||||
<Statistic
|
||||
title={t("bills.labels.subtotal")}
|
||||
value={totals.subtotal.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
<Statistic
|
||||
title={t("bills.labels.federal_tax")}
|
||||
value={totals.federalTax.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
<Statistic
|
||||
title={t("bills.labels.state_tax")}
|
||||
value={totals.stateTax.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
<Statistic
|
||||
title={t("bills.labels.local_tax")}
|
||||
value={totals.localTax.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
<Statistic
|
||||
title={t("bills.labels.entered_total")}
|
||||
value={totals.enteredTotal.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
<Statistic
|
||||
title={t("bills.labels.bill_total")}
|
||||
value={totals.invoiceTotal.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
<Statistic
|
||||
title={t("bills.labels.discrepancy")}
|
||||
valueStyle={{
|
||||
color:
|
||||
totals.discrepancy.getAmount() === 0 ? "green" : "red",
|
||||
}}
|
||||
value={totals.discrepancy.toFormat()}
|
||||
precision={2}
|
||||
/>
|
||||
</Space>
|
||||
{form.getFieldValue("is_credit_memo") ? (
|
||||
<AlertComponent
|
||||
type="warning"
|
||||
message={t("bills.labels.enteringcreditmemo")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BillFormComponent);
|
||||
36
client/src/components/bill-form/bill-form.container.jsx
Normal file
36
client/src/components/bill-form/bill-form.container.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useLazyQuery, useQuery } from "@apollo/react-hooks";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { GET_JOB_LINES_TO_ENTER_BILL } from "../../graphql/jobs-lines.queries";
|
||||
import { SEARCH_VENDOR_AUTOCOMPLETE } from "../../graphql/vendors.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import BillFormComponent from "./bill-form.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
});
|
||||
|
||||
export function BillFormContainer({ bodyshop, form, billEdit }) {
|
||||
const { data: VendorAutoCompleteData } = useQuery(SEARCH_VENDOR_AUTOCOMPLETE);
|
||||
|
||||
const [loadLines, { data: lineData }] = useLazyQuery(
|
||||
GET_JOB_LINES_TO_ENTER_BILL
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BillFormComponent
|
||||
form={form}
|
||||
billEdit={billEdit}
|
||||
vendorAutoCompleteOptions={
|
||||
VendorAutoCompleteData && VendorAutoCompleteData.vendors
|
||||
}
|
||||
loadLines={loadLines}
|
||||
lineData={lineData ? lineData.joblines : []}
|
||||
responsibilityCenters={bodyshop.md_responsibility_centers || null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default connect(mapStateToProps, null)(BillFormContainer);
|
||||
239
client/src/components/bill-form/bill-form.lines.component.jsx
Normal file
239
client/src/components/bill-form/bill-form.lines.component.jsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { DeleteFilled, WarningOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Switch,
|
||||
} from "antd";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
export default function BillEnterModalLinesComponent({
|
||||
lineData,
|
||||
discount,
|
||||
form,
|
||||
responsibilityCenters,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setFieldsValue, getFieldsValue } = form;
|
||||
|
||||
return (
|
||||
<Form.List name="billlines">
|
||||
{(fields, { add, remove, move }) => {
|
||||
return (
|
||||
<div className="invoice-form-lines-wrapper">
|
||||
{fields.map((field, index) => (
|
||||
<Form.Item required={false} key={field.key}>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<LayoutFormRow style={{ flex: 1 }} grow>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.jobline")}
|
||||
key={`${index}joblinename`}
|
||||
name={[field.name, "joblineid"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<BillLineSearchSelect
|
||||
options={lineData}
|
||||
onSelect={(value, opt) => {
|
||||
setFieldsValue({
|
||||
billlines: getFieldsValue([
|
||||
"billlines",
|
||||
]).billlines.map((item, idx) => {
|
||||
if (idx === index) {
|
||||
return {
|
||||
...item,
|
||||
line_desc: opt.line_desc,
|
||||
quantity: opt.part_qty || 1,
|
||||
actual_price: opt.cost,
|
||||
cost_center: opt.part_type
|
||||
? responsibilityCenters.defaults.costs[
|
||||
opt.part_type
|
||||
] || null
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("billlines.fields.line_desc")}
|
||||
key={`${index}line_desc`}
|
||||
name={[field.name, "line_desc"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.quantity")}
|
||||
key={`${index}quantity`}
|
||||
name={[field.name, "quantity"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber precision={0} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.actual")}
|
||||
key={`${index}actual_price`}
|
||||
name={[field.name, "actual_price"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
onBlur={(e) => {
|
||||
setFieldsValue({
|
||||
billlines: getFieldsValue(
|
||||
"billlines"
|
||||
).billlines.map((item, idx) => {
|
||||
if (idx === index) {
|
||||
return {
|
||||
...item,
|
||||
actual_cost: !!item.actual_cost
|
||||
? item.actual_cost
|
||||
: parseFloat(e.target.value) *
|
||||
(1 - discount),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.actual_cost")}
|
||||
key={`${index}actual_cost`}
|
||||
name={[field.name, "actual_cost"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<CurrencyInput min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
const line = getFieldsValue(["billlines"]).billlines[
|
||||
index
|
||||
];
|
||||
if (!!!line) return null;
|
||||
const lineDiscount = (
|
||||
1 -
|
||||
Math.round(
|
||||
(line.actual_cost / line.actual_price) * 100
|
||||
) /
|
||||
100
|
||||
).toPrecision(2);
|
||||
|
||||
if (lineDiscount - discount === 0) return <div />;
|
||||
return <WarningOutlined style={{ color: "red" }} />;
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.cost_center")}
|
||||
key={`${index}cost_center`}
|
||||
name={[field.name, "cost_center"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t("general.validation.required"),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select style={{ width: "150px" }}>
|
||||
{responsibilityCenters.costs.map((item) => (
|
||||
<Select.Option key={item.name}>
|
||||
{item.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.federal_tax_applicable")}
|
||||
key={`${index}fedtax`}
|
||||
initialValue={true}
|
||||
valuePropName="checked"
|
||||
name={[field.name, "applicable_taxes", "federal"]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.state_tax_applicable")}
|
||||
key={`${index}statetax`}
|
||||
valuePropName="checked"
|
||||
name={[field.name, "applicable_taxes", "state"]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.local_tax_applicable")}
|
||||
key={`${index}localtax`}
|
||||
valuePropName="checked"
|
||||
name={[field.name, "applicable_taxes", "local"]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<FormListMoveArrows
|
||||
move={move}
|
||||
index={index}
|
||||
total={fields.length}
|
||||
/>
|
||||
<DeleteFilled
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
</div>
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button
|
||||
onClick={() => {
|
||||
add();
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("billlines.actions.newline")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.List>
|
||||
);
|
||||
}
|
||||
53
client/src/components/bill-form/bill-form.totals.utility.js
Normal file
53
client/src/components/bill-form/bill-form.totals.utility.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import Dinero from "dinero.js";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
|
||||
export const CalculateBillTotal = (invoice) => {
|
||||
logImEXEvent("invoice_calculate_total");
|
||||
|
||||
const {
|
||||
total,
|
||||
billlines,
|
||||
federal_tax_rate,
|
||||
local_tax_rate,
|
||||
state_tax_rate,
|
||||
} = invoice;
|
||||
//TODO Determine why this recalculates so many times.
|
||||
let subtotal = Dinero({ amount: 0 });
|
||||
let federalTax = Dinero({ amount: 0 });
|
||||
let stateTax = Dinero({ amount: 0 });
|
||||
let localTax = Dinero({ amount: 0 });
|
||||
|
||||
if (!!!billlines) return null;
|
||||
|
||||
billlines.forEach((i) => {
|
||||
if (!!i) {
|
||||
const itemTotal = Dinero({
|
||||
amount: Math.round((i.actual_cost || 0) * 100) || 0,
|
||||
}).multiply(i.quantity || 1);
|
||||
subtotal = subtotal.add(itemTotal);
|
||||
if (i.applicable_taxes.federal) {
|
||||
federalTax = federalTax.add(
|
||||
itemTotal.percentage(federal_tax_rate || 0)
|
||||
);
|
||||
}
|
||||
if (i.applicable_taxes.state)
|
||||
stateTax = stateTax.add(itemTotal.percentage(state_tax_rate || 0));
|
||||
if (i.applicable_taxes.local)
|
||||
localTax = localTax.add(itemTotal.percentage(local_tax_rate || 0));
|
||||
}
|
||||
});
|
||||
|
||||
const invoiceTotal = Dinero({ amount: Math.round((total || 0) * 100) });
|
||||
const enteredTotal = subtotal.add(federalTax).add(stateTax).add(localTax);
|
||||
const discrepancy = enteredTotal.subtract(invoiceTotal);
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
federalTax,
|
||||
stateTax,
|
||||
localTax,
|
||||
enteredTotal,
|
||||
invoiceTotal,
|
||||
discrepancy,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user