351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
import { Alert, Button, Card, Tabs, Typography } from "antd";
|
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
|
import { SyncOutlined } from "@ant-design/icons";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { connect } from "react-redux";
|
|
import { createStructuredSelector } from "reselect";
|
|
import { resolveRROpCodeFromBodyshop } from "../../utils/dmsUtils.js";
|
|
|
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
|
|
|
const mapStateToProps = createStructuredSelector({
|
|
bodyshop: selectBodyshop
|
|
});
|
|
const mapDispatchToProps = () => ({});
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(RrAllocationsSummary);
|
|
|
|
/**
|
|
* Normalize job allocations into a flat list for display / preview building.
|
|
* @param ack
|
|
* @returns {{
|
|
* center: *,
|
|
* sale: *,
|
|
* partsSale: *,
|
|
* partsTaxableSale: *,
|
|
* partsNonTaxableSale: *,
|
|
* laborTaxableSale: *,
|
|
* laborNonTaxableSale: *,
|
|
* extrasSale: *,
|
|
* extrasTaxableSale: *,
|
|
* extrasNonTaxableSale: *,
|
|
* cost: *,
|
|
* profitCenter: *,
|
|
* costCenter: *
|
|
* }[]|*[]}
|
|
*/
|
|
function normalizeJobAllocations(ack) {
|
|
if (!ack || !Array.isArray(ack.jobAllocations)) return [];
|
|
|
|
return ack.jobAllocations.map((row) => ({
|
|
center: row.center,
|
|
|
|
// legacy "sale" (total) if we ever want to show it again
|
|
sale: row.sale || row.totalSale || null,
|
|
|
|
// bucketed sales used to build split ROGOG/ROLABOR
|
|
partsSale: row.partsSale || null,
|
|
partsTaxableSale: row.partsTaxableSale || null,
|
|
partsNonTaxableSale: row.partsNonTaxableSale || null,
|
|
laborTaxableSale: row.laborTaxableSale || null,
|
|
laborNonTaxableSale: row.laborNonTaxableSale || null,
|
|
extrasSale: row.extrasSale || null,
|
|
extrasTaxableSale: row.extrasTaxableSale || null,
|
|
extrasNonTaxableSale: row.extrasNonTaxableSale || null,
|
|
|
|
cost: row.cost || null,
|
|
profitCenter: row.profitCenter || null,
|
|
costCenter: row.costCenter || null
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* RR-specific DMS Allocations Summary
|
|
* Focused on what we actually send to RR:
|
|
* - ROGOG (split by taxable / non-taxable segments)
|
|
* - ROLABOR shell
|
|
*
|
|
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
|
|
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
|
|
* This component just renders the preview from `ack.rogg` / `ack.rolabor`.
|
|
*/
|
|
export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocationsChange, opCode }) {
|
|
const { t } = useTranslation();
|
|
const [roggPreview, setRoggPreview] = useState(null);
|
|
const [rolaborPreview, setRolaborPreview] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const socketRef = useRef(socket);
|
|
|
|
useEffect(() => {
|
|
socketRef.current = socket;
|
|
}, [socket]);
|
|
|
|
// Prefer the user-selected OpCode (from DmsContainer), fall back to config default
|
|
const effectiveOpCode = useMemo(() => opCode || resolveRROpCodeFromBodyshop(bodyshop), [opCode, bodyshop]);
|
|
|
|
const fetchAllocations = useCallback(() => {
|
|
if (!socket || !jobId) return;
|
|
|
|
try {
|
|
socket.emit("rr-calculate-allocations", { jobId, opCode: effectiveOpCode }, (ack) => {
|
|
if (ack && ack.ok === false) {
|
|
setRoggPreview(null);
|
|
setRolaborPreview(null);
|
|
setError(ack.error || t("dms.labels.allocations_error"));
|
|
if (socketRef.current) {
|
|
socketRef.current.allocationsSummary = [];
|
|
socketRef.current.rrAllocationsRaw = ack;
|
|
}
|
|
if (onAllocationsChange) {
|
|
onAllocationsChange([]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const jobAllocRows = normalizeJobAllocations(ack);
|
|
|
|
setRoggPreview(ack?.rogg || null);
|
|
setRolaborPreview(ack?.rolabor || null);
|
|
setError(null);
|
|
|
|
if (socketRef.current) {
|
|
socketRef.current.allocationsSummary = jobAllocRows;
|
|
socketRef.current.rrAllocationsRaw = ack;
|
|
}
|
|
if (onAllocationsChange) {
|
|
onAllocationsChange(jobAllocRows);
|
|
}
|
|
});
|
|
} catch {
|
|
setRoggPreview(null);
|
|
setRolaborPreview(null);
|
|
setError(t("dms.labels.allocations_error"));
|
|
if (socketRef.current) {
|
|
socketRef.current.allocationsSummary = [];
|
|
}
|
|
if (onAllocationsChange) {
|
|
onAllocationsChange([]);
|
|
}
|
|
}
|
|
}, [socket, jobId, t, onAllocationsChange, effectiveOpCode]);
|
|
|
|
useEffect(() => {
|
|
fetchAllocations();
|
|
}, [fetchAllocations]);
|
|
|
|
const segmentLabelMap = {
|
|
partsTaxable: "Parts Taxable",
|
|
partsNonTaxable: "Parts Non-Taxable",
|
|
extrasTaxable: "Extras Taxable",
|
|
extrasNonTaxable: "Extras Non-Taxable",
|
|
laborTaxable: "Labor Taxable",
|
|
laborNonTaxable: "Labor Non-Taxable"
|
|
};
|
|
|
|
const roggRows = useMemo(() => {
|
|
if (!roggPreview || !Array.isArray(roggPreview.ops)) return [];
|
|
|
|
const rows = [];
|
|
roggPreview.ops.forEach((op) => {
|
|
const rowOpCode = opCode || op.opCode;
|
|
|
|
(op.lines || []).forEach((line, idx) => {
|
|
const baseDesc = line.itemDesc;
|
|
const segmentKind = op.segmentKind;
|
|
const segmentCount = op.segmentCount || 0;
|
|
const segmentLabel = segmentLabelMap[segmentKind] || segmentKind;
|
|
const displayDesc = segmentCount > 1 && segmentLabel ? `${baseDesc} (${segmentLabel})` : baseDesc;
|
|
|
|
rows.push({
|
|
key: `${op.jobNo}-${idx}`,
|
|
opCode: rowOpCode,
|
|
jobNo: op.jobNo,
|
|
breakOut: line.breakOut,
|
|
itemType: line.itemType,
|
|
itemDesc: displayDesc,
|
|
custQty: line.custQty,
|
|
custPayTypeFlag: line.custPayTypeFlag,
|
|
custTxblNtxblFlag: line.custTxblNtxblFlag,
|
|
custPrice: line.amount?.custPrice,
|
|
dlrCost: line.amount?.dlrCost,
|
|
// segment metadata for visual styling
|
|
segmentKind,
|
|
segmentCount
|
|
});
|
|
});
|
|
});
|
|
return rows;
|
|
}, [roggPreview, opCode, segmentLabelMap]);
|
|
|
|
const rolaborRows = useMemo(() => {
|
|
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
|
|
|
|
return rolaborPreview.ops.map((op, idx) => {
|
|
const rowOpCode = opCode || op.opCode;
|
|
|
|
return {
|
|
key: `${op.jobNo}-${idx}`,
|
|
opCode: rowOpCode,
|
|
jobNo: op.jobNo,
|
|
custPayTypeFlag: op.custPayTypeFlag,
|
|
custTxblNtxblFlag: op.custTxblNtxblFlag,
|
|
payType: op.bill?.payType,
|
|
amtType: op.amount?.amtType,
|
|
custPrice: op.amount?.custPrice,
|
|
totalAmt: op.amount?.totalAmt
|
|
};
|
|
});
|
|
}, [rolaborPreview, opCode]);
|
|
|
|
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
|
|
const roggTotals = useMemo(() => {
|
|
if (!roggPreview || !Array.isArray(roggPreview.ops)) {
|
|
return { totalCustPrice: "0.00", totalDlrCost: "0.00" };
|
|
}
|
|
|
|
let totalCustCents = 0;
|
|
let totalCostCents = 0;
|
|
|
|
roggPreview.ops.forEach((op) => {
|
|
(op.lines || []).forEach((line) => {
|
|
const cp = parseFloat(line.amount?.custPrice || "0");
|
|
if (!Number.isNaN(cp)) {
|
|
totalCustCents += Math.round(cp * 100);
|
|
}
|
|
|
|
const dc = parseFloat(line.amount?.dlrCost || "0");
|
|
if (!Number.isNaN(dc)) {
|
|
totalCostCents += Math.round(dc * 100);
|
|
}
|
|
});
|
|
});
|
|
|
|
return {
|
|
totalCustPrice: (totalCustCents / 100).toFixed(2),
|
|
totalDlrCost: (totalCostCents / 100).toFixed(2)
|
|
};
|
|
}, [roggPreview]);
|
|
|
|
const roggColumns = [
|
|
{ title: "JobNo", dataIndex: "jobNo", key: "jobNo" },
|
|
{ title: "OpCode", dataIndex: "opCode", key: "opCode" },
|
|
{ title: "BreakOut", dataIndex: "breakOut", key: "breakOut" },
|
|
{ title: "ItemType", dataIndex: "itemType", key: "itemType" },
|
|
{ title: "ItemDesc", dataIndex: "itemDesc", key: "itemDesc" },
|
|
{ title: "CustQty", dataIndex: "custQty", key: "custQty" },
|
|
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
|
|
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
|
|
{ title: "DlrCost", dataIndex: "dlrCost", key: "dlrCost" }
|
|
];
|
|
|
|
const rolaborColumns = [
|
|
{ title: "JobNo", dataIndex: "jobNo", key: "jobNo" },
|
|
{ title: "OpCode", dataIndex: "opCode", key: "opCode" },
|
|
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
|
|
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
|
|
{ title: "PayType", dataIndex: "payType", key: "payType" },
|
|
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
|
|
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
|
|
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
|
|
];
|
|
|
|
const tabItems = [
|
|
{
|
|
key: "rogog",
|
|
label: "ROGOG Preview",
|
|
children: (
|
|
<>
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
|
OpCode: <strong>{effectiveOpCode}</strong>. Only centers with RR GOG mapping (rr_gogcode & rr_item_type)
|
|
are included. Totals below reflect exactly what will be sent in ROGOG, with parts, extras, and labor split
|
|
into taxable / non-taxable segments.
|
|
</Typography.Paragraph>
|
|
|
|
<ResponsiveTable
|
|
pagination={false}
|
|
columns={roggColumns}
|
|
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
|
|
rowKey="key"
|
|
dataSource={roggRows}
|
|
locale={{ emptyText: "No ROGOG lines would be generated." }}
|
|
scroll={{ x: true }}
|
|
// 👇 visually highlight splits; especially taxable/non-taxable labor segments
|
|
rowClassName={(record) => {
|
|
if (
|
|
record.segmentCount > 1 &&
|
|
(record.segmentKind === "laborTaxable" || record.segmentKind === "laborNonTaxable")
|
|
) {
|
|
return "rr-allocations-tax-split-row";
|
|
}
|
|
if (record.segmentCount > 1) {
|
|
return "rr-allocations-split-row";
|
|
}
|
|
return "";
|
|
}}
|
|
summary={() => {
|
|
const hasCustTotal = Number(roggTotals.totalCustPrice) !== 0;
|
|
const hasCostTotal = Number(roggTotals.totalDlrCost) !== 0;
|
|
|
|
return (
|
|
<ResponsiveTable.Summary.Row>
|
|
<ResponsiveTable.Summary.Cell index={0}>
|
|
<Typography.Title level={5}>{t("general.labels.totals")}</Typography.Title>
|
|
</ResponsiveTable.Summary.Cell>
|
|
<ResponsiveTable.Summary.Cell index={1} />
|
|
<ResponsiveTable.Summary.Cell index={2} />
|
|
<ResponsiveTable.Summary.Cell index={3} />
|
|
<ResponsiveTable.Summary.Cell index={4} />
|
|
<ResponsiveTable.Summary.Cell index={5} />
|
|
<ResponsiveTable.Summary.Cell index={6} />
|
|
<ResponsiveTable.Summary.Cell index={7}>
|
|
{hasCustTotal ? roggTotals.totalCustPrice : null}
|
|
</ResponsiveTable.Summary.Cell>
|
|
<ResponsiveTable.Summary.Cell index={8}>
|
|
{hasCostTotal ? roggTotals.totalDlrCost : null}
|
|
</ResponsiveTable.Summary.Cell>
|
|
</ResponsiveTable.Summary.Row>
|
|
);
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
},
|
|
{
|
|
key: "rolabor",
|
|
label: "ROLABOR Preview",
|
|
children: (
|
|
<>
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
|
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
|
|
</Typography.Paragraph>
|
|
<ResponsiveTable
|
|
pagination={false}
|
|
columns={rolaborColumns}
|
|
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
|
|
rowKey="key"
|
|
dataSource={rolaborRows}
|
|
locale={{ emptyText: "No ROLABOR lines would be generated." }}
|
|
scroll={{ x: true }}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
];
|
|
|
|
return (
|
|
<Card
|
|
title={title}
|
|
extra={<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")} icon={<SyncOutlined />} />}
|
|
>
|
|
{bodyshop.pbs_configuration?.disablebillwip && (
|
|
<Alert type="warning" title={t("jobs.labels.dms.disablebillwip")} />
|
|
)}
|
|
|
|
{error && <Alert type="error" style={{ marginTop: 8, marginBottom: 8 }} title={error} />}
|
|
|
|
<Tabs defaultActiveKey="rogog" items={tabItems} />
|
|
</Card>
|
|
);
|
|
}
|