feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration / RRScratch2 / Checkpoint
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
import { Alert, Button, Card, Table, Tabs, Typography } from "antd";
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import Dinero from "dinero.js";
|
||||
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(RrAllocationsSummary);
|
||||
|
||||
function normalizeDineroJson(d) {
|
||||
if (!d) return null;
|
||||
|
||||
// If it's already a Dinero instance, leave it to the caller
|
||||
if (typeof d.toUnit === "function") return d;
|
||||
|
||||
// New server shape: { cents: 54144, currency?: "USD" }
|
||||
if (typeof d.cents === "number") {
|
||||
return {
|
||||
amount: d.cents,
|
||||
precision: 2,
|
||||
currency: d.currency || "USD"
|
||||
};
|
||||
}
|
||||
|
||||
// Classic Dinero JSON: { amount, precision, currency }
|
||||
if (typeof d.amount === "number") {
|
||||
return {
|
||||
amount: d.amount,
|
||||
precision: d.precision ?? 2,
|
||||
currency: d.currency || "USD"
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Dinero-like object into an "N2" string ("123.45").
|
||||
* Works with real Dinero instances or plain JSON objects
|
||||
* that have { amount, precision }.
|
||||
*/
|
||||
function dineroToN2(dineroLike) {
|
||||
if (!dineroLike) return "0.00";
|
||||
|
||||
// If it's an actual Dinero instance
|
||||
if (typeof dineroLike.toUnit === "function") {
|
||||
return dineroLike.toUnit().toFixed(2);
|
||||
}
|
||||
|
||||
const normalized = normalizeDineroJson(dineroLike);
|
||||
if (!normalized) return "0.00";
|
||||
|
||||
const { amount, precision = 2 } = normalized;
|
||||
const value = amount / Math.pow(10, precision);
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize job allocations into a flat list for display / preview building.
|
||||
* @param ack
|
||||
* @returns {{center: *, sale, partsSale, laborTaxableSale, laborNonTaxableSale, extrasSale, 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,
|
||||
laborTaxableSale: row.laborTaxableSale || null,
|
||||
laborNonTaxableSale: row.laborNonTaxableSale || null,
|
||||
extrasSale: row.extrasSale || null,
|
||||
|
||||
cost: row.cost || null,
|
||||
profitCenter: row.profitCenter || null,
|
||||
costCenter: row.costCenter || null
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal ROGOG preview from job allocation rows.
|
||||
* Mirrors the backend buildRogogFromAllocations logic:
|
||||
* - parts+extras segment
|
||||
* - taxable labor segment
|
||||
* - non-taxable labor segment
|
||||
* Each segment becomes its *own* JobNo with a single GOG line.
|
||||
*/
|
||||
function buildRogogPreviewFromJobRows(jobRows, opCode) {
|
||||
if (!Array.isArray(jobRows) || !jobRows.length || !opCode) return null;
|
||||
|
||||
const ops = [];
|
||||
|
||||
const toDinero = (d) => {
|
||||
if (!d) return Dinero();
|
||||
if (typeof d.toUnit === "function") return d;
|
||||
|
||||
const normalized = normalizeDineroJson(d);
|
||||
return normalized ? Dinero(normalized) : Dinero();
|
||||
};
|
||||
|
||||
const cents = (d) => toDinero(d).getAmount();
|
||||
|
||||
const segmentLabelMap = {
|
||||
partsExtras: "Parts/Extras",
|
||||
laborTaxable: "Taxable Labor",
|
||||
laborNonTaxable: "Non-Taxable Labor"
|
||||
};
|
||||
|
||||
for (const row of jobRows) {
|
||||
const pc = row.profitCenter || {};
|
||||
const breakOut = pc.rr_gogcode;
|
||||
const itemType = pc.rr_item_type;
|
||||
|
||||
// Only centers configured for RR GOG should appear
|
||||
if (!breakOut || !itemType) continue;
|
||||
|
||||
// Bucketed sales (Dinero-like objects coming from the backend)
|
||||
const partsSale = toDinero(row.partsSale);
|
||||
const extrasSale = toDinero(row.extrasSale);
|
||||
const laborTaxableSale = toDinero(row.laborTaxableSale);
|
||||
const laborNonTaxableSale = toDinero(row.laborNonTaxableSale);
|
||||
const costMoney = toDinero(row.cost);
|
||||
|
||||
const partsExtrasSale = partsSale.add(extrasSale);
|
||||
|
||||
const segments = [];
|
||||
|
||||
// 1) Parts + extras segment (uses center's default tax flag)
|
||||
if (!partsExtrasSale.isZero()) {
|
||||
segments.push({
|
||||
kind: "partsExtras",
|
||||
sale: partsExtrasSale,
|
||||
txFlag: pc.rr_cust_txbl_flag || "T"
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Taxable labor -> always "T"
|
||||
if (!laborTaxableSale.isZero()) {
|
||||
segments.push({
|
||||
kind: "laborTaxable",
|
||||
sale: laborTaxableSale,
|
||||
txFlag: "T"
|
||||
});
|
||||
}
|
||||
|
||||
// 3) Non-taxable labor -> always "N"
|
||||
if (!laborNonTaxableSale.isZero()) {
|
||||
segments.push({
|
||||
kind: "laborNonTaxable",
|
||||
sale: laborNonTaxableSale,
|
||||
txFlag: "N"
|
||||
});
|
||||
}
|
||||
|
||||
if (!segments.length) continue;
|
||||
|
||||
// Proportionally split cost across segments (same logic as backend)
|
||||
const totalCostCents = cents(costMoney);
|
||||
const totalSaleCents = segments.reduce((sum, seg) => sum + cents(seg.sale), 0);
|
||||
|
||||
let remainingCostCents = totalCostCents;
|
||||
|
||||
segments.forEach((seg, idx) => {
|
||||
let costCents = 0;
|
||||
|
||||
if (totalCostCents > 0 && totalSaleCents > 0) {
|
||||
if (idx === segments.length - 1) {
|
||||
// Last segment gets the remainder to avoid rounding drift
|
||||
costCents = remainingCostCents;
|
||||
} else {
|
||||
const segSaleCents = cents(seg.sale);
|
||||
costCents = Math.round((segSaleCents / totalSaleCents) * totalCostCents);
|
||||
remainingCostCents -= costCents;
|
||||
}
|
||||
}
|
||||
|
||||
seg.costCents = costCents;
|
||||
});
|
||||
|
||||
const itemDescBase = pc.accountdesc || pc.accountname || row.center || "";
|
||||
|
||||
// 🔑 Each segment becomes its own JobNo with a single GOG line
|
||||
segments.forEach((seg, segIndex) => {
|
||||
const jobNo = String(ops.length + 1); // 1-based, global across all centers
|
||||
const segmentCount = segments.length;
|
||||
const segmentKind = seg.kind;
|
||||
const segmentLabel = segmentLabelMap[segmentKind] || segmentKind;
|
||||
|
||||
// If there is a split, annotate the description so it’s obvious which segment this is
|
||||
const displayDesc = segmentCount > 1 ? `${itemDescBase} (${segmentLabel})` : itemDescBase;
|
||||
|
||||
const saleN2 = dineroToN2(seg.sale);
|
||||
const costN2 = dineroToN2({
|
||||
amount: seg.costCents || 0,
|
||||
precision: 2
|
||||
});
|
||||
|
||||
ops.push({
|
||||
opCode,
|
||||
jobNo,
|
||||
segmentKind,
|
||||
segmentIndex: segIndex,
|
||||
segmentCount,
|
||||
lines: [
|
||||
{
|
||||
breakOut,
|
||||
itemType,
|
||||
itemDesc: displayDesc,
|
||||
custQty: "1.0",
|
||||
custPayTypeFlag: "C",
|
||||
// canonical property name used on the server
|
||||
custTxblNTxblFlag: seg.txFlag || "T",
|
||||
// legacy alias used by the table
|
||||
custTxblNtxblFlag: seg.txFlag || "T",
|
||||
amount: {
|
||||
payType: "Cust",
|
||||
amtType: "Unit",
|
||||
custPrice: saleN2,
|
||||
dlrCost: costN2
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!ops.length) return null;
|
||||
|
||||
return {
|
||||
roNo: null, // preview only
|
||||
ops
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal ROLABOR preview from a ROGOG preview.
|
||||
* Mirrors server-side buildRolaborFromRogog.
|
||||
*/
|
||||
function buildRolaborPreviewFromRogog(rogg) {
|
||||
if (!rogg || !Array.isArray(rogg.ops)) return null;
|
||||
|
||||
const ops = rogg.ops.map((op) => {
|
||||
const firstLine = op.lines?.[0] || {};
|
||||
|
||||
// Prefer the server-side property name, but fall back to legacy
|
||||
const txFlag = firstLine.custTxblNTxblFlag ?? firstLine.custTxblNtxblFlag ?? "N";
|
||||
const payFlag = firstLine.custPayTypeFlag || "C";
|
||||
|
||||
return {
|
||||
opCode: op.opCode,
|
||||
jobNo: op.jobNo,
|
||||
custPayTypeFlag: payFlag,
|
||||
// this is what the table uses
|
||||
custTxblNtxblFlag: txFlag,
|
||||
bill: {
|
||||
payType: "Cust",
|
||||
jobTotalHrs: "0",
|
||||
billTime: "0",
|
||||
billRate: "0"
|
||||
},
|
||||
amount: {
|
||||
payType: "Cust",
|
||||
amtType: "Job",
|
||||
custPrice: "0",
|
||||
totalAmt: "0"
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (!ops.length) return null;
|
||||
return { ops };
|
||||
}
|
||||
|
||||
/**
|
||||
* RR-specific DMS Allocations Summary
|
||||
* Focused on what we actually send to RR:
|
||||
* - ROGOG (split by taxable / non-taxable segments)
|
||||
* - ROLABOR shell
|
||||
*/
|
||||
export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) {
|
||||
const { t } = useTranslation();
|
||||
const [jobRows, setJobRows] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchAllocations = useCallback(() => {
|
||||
if (!socket || !jobId) return;
|
||||
|
||||
try {
|
||||
socket.emit("rr-calculate-allocations", jobId, (ack) => {
|
||||
if (ack && ack.ok === false) {
|
||||
setJobRows([]);
|
||||
setError(ack.error || t("dms.labels.allocations_error"));
|
||||
if (socket) {
|
||||
socket.allocationsSummary = [];
|
||||
socket.rrAllocationsRaw = ack;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const jobAllocRows = normalizeJobAllocations(ack);
|
||||
|
||||
setJobRows(jobAllocRows);
|
||||
setError(null);
|
||||
|
||||
if (socket) {
|
||||
socket.allocationsSummary = jobAllocRows;
|
||||
socket.rrAllocationsRaw = ack;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
setJobRows([]);
|
||||
setError(t("dms.labels.allocations_error"));
|
||||
if (socket) {
|
||||
socket.allocationsSummary = [];
|
||||
}
|
||||
}
|
||||
}, [socket, jobId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllocations();
|
||||
}, [fetchAllocations]);
|
||||
|
||||
const opCode = bodyshop?.rr_configuration?.baseOpCode || "28TOZ";
|
||||
|
||||
const roggPreview = useMemo(() => buildRogogPreviewFromJobRows(jobRows, opCode), [jobRows, opCode]);
|
||||
const rolaborPreview = useMemo(() => buildRolaborPreviewFromRogog(roggPreview), [roggPreview]);
|
||||
|
||||
const roggRows = useMemo(() => {
|
||||
if (!roggPreview || !Array.isArray(roggPreview.ops)) return [];
|
||||
const rows = [];
|
||||
roggPreview.ops.forEach((op) => {
|
||||
(op.lines || []).forEach((line, idx) => {
|
||||
rows.push({
|
||||
key: `${op.jobNo}-${idx}`,
|
||||
opCode: op.opCode,
|
||||
jobNo: op.jobNo,
|
||||
breakOut: line.breakOut,
|
||||
itemType: line.itemType,
|
||||
itemDesc: line.itemDesc,
|
||||
custQty: line.custQty,
|
||||
custPayTypeFlag: line.custPayTypeFlag,
|
||||
custTxblNtxblFlag: line.custTxblNtxblFlag,
|
||||
custPrice: line.amount?.custPrice,
|
||||
dlrCost: line.amount?.dlrCost,
|
||||
// segment metadata for visual styling
|
||||
segmentKind: op.segmentKind,
|
||||
segmentCount: op.segmentCount
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}, [roggPreview]);
|
||||
|
||||
const rolaborRows = useMemo(() => {
|
||||
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
|
||||
return rolaborPreview.ops.map((op, idx) => ({
|
||||
key: `${op.jobNo}-${idx}`,
|
||||
opCode: op.opCode,
|
||||
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]);
|
||||
|
||||
// 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>{opCode}</strong>. Only centers with RR GOG mapping (rr_gogcode & rr_item_type) are
|
||||
included. Totals below reflect exactly what will be sent in ROGOG.
|
||||
</Typography.Paragraph>
|
||||
<Table
|
||||
pagination={false}
|
||||
columns={roggColumns}
|
||||
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={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<Typography.Title level={5}>{t("general.labels.totals")}</Typography.Title>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} />
|
||||
<Table.Summary.Cell index={2} />
|
||||
<Table.Summary.Cell index={3} />
|
||||
<Table.Summary.Cell index={4} />
|
||||
<Table.Summary.Cell index={5} />
|
||||
<Table.Summary.Cell index={6} />
|
||||
<Table.Summary.Cell index={7}>{roggTotals.totalCustPrice}</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={8}>{roggTotals.totalDlrCost}</Table.Summary.Cell>
|
||||
</Table.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>
|
||||
<Table
|
||||
pagination={false}
|
||||
columns={rolaborColumns}
|
||||
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")}>
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{bodyshop.pbs_configuration?.disablebillwip && (
|
||||
<Alert type="warning" message={t("jobs.labels.dms.disablebillwip")} />
|
||||
)}
|
||||
|
||||
{error && <Alert type="error" style={{ marginTop: 8, marginBottom: 8 }} message={error} />}
|
||||
|
||||
<Tabs defaultActiveKey="rogog" items={tabItems} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,14 @@ const mapDispatchToProps = () => ({});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DmsLogEvents);
|
||||
|
||||
export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colorizeJson = false }) {
|
||||
export function DmsLogEvents({
|
||||
logs,
|
||||
detailsOpen,
|
||||
detailsNonce,
|
||||
isDarkMode,
|
||||
colorizeJson = false,
|
||||
showDetails = true
|
||||
}) {
|
||||
const [openSet, setOpenSet] = useState(() => new Set());
|
||||
|
||||
// Inject JSON highlight styles once (only when colorize is enabled)
|
||||
@@ -54,8 +61,10 @@ export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colo
|
||||
() =>
|
||||
(logs || []).map((raw, idx) => {
|
||||
const { level, message, timestamp, meta } = normalizeLog(raw);
|
||||
const hasMeta = !isEmpty(meta);
|
||||
const isOpen = openSet.has(idx);
|
||||
|
||||
// Only treat meta as "present" when we are allowed to show details
|
||||
const hasMeta = !isEmpty(meta) && showDetails;
|
||||
const isOpen = hasMeta && openSet.has(idx);
|
||||
|
||||
return {
|
||||
key: idx,
|
||||
@@ -101,7 +110,7 @@ export function DmsLogEvents({ logs, detailsOpen, detailsNonce, isDarkMode, colo
|
||||
)
|
||||
};
|
||||
}),
|
||||
[logs, openSet, colorizeJson]
|
||||
[logs, openSet, colorizeJson, isDarkMode, showDetails]
|
||||
);
|
||||
|
||||
return <Timeline pending reverse items={items} />;
|
||||
|
||||
Reference in New Issue
Block a user