Skip to content
46 changes: 26 additions & 20 deletions src/components/sponsors/reports/ByItemView.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import T from "i18n-react/dist/i18n-react";
import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money";
import StatusPill from "./StatusPill";
import ChipList from "../../mui/chip-list";
import { Destination, PER_PAGE_OPTIONS } from "./LinesManifestView";
import {
Destination,
LineStatusPill,
PER_PAGE_OPTIONS,
lineStatus,
liveQuantity,
liveAmountCents
} from "./LinesManifestView";
import { formatCheckoutTime } from "./OrdersTable";
import {
DEFAULT_CURRENT_PAGE,
Expand Down Expand Up @@ -85,16 +91,11 @@ const accumulateRow = (itemMap, row) => {
item.label = row.description.trim();
}
item.lines += 1;
// Canceled lines are shown struck-through in the drill-down (as contributors
// below) but excluded from ALL "purchased" aggregates: qty, money, orders,
// and statusMix. Counting them would let a canceled-only line report an item
// as purchased (Qty 0 next to Orders 1 / Paid 1). A mixed order keeps its
// count because the live line for the same item still adds the order id.
if (!row.is_canceled) {
item.qty += row.quantity ?? 0;
// Null-safe money: all-null stays null (renders "—"); mixed sums non-nulls.
if (row.line_total != null) {
item.totalCents = (item.totalCents ?? 0) + row.line_total;
item.qty += liveQuantity(row);
const liveCents = liveAmountCents(row);
if (liveCents != null) {
item.totalCents = (item.totalCents ?? 0) + liveCents;
}
const purchaseId = row.purchase?.id ?? null;
if (purchaseId != null) {
Expand All @@ -116,11 +117,15 @@ const accumulateRow = (itemMap, row) => {
sponsorBooth: row.sponsor_booth ?? null,
checkoutAt: row.purchase?.checkout_at ?? null,
rateName: row.rate_name ?? "",
// the line's own state: a soft-canceled line leaves its parent order Paid
status: row.is_canceled ? "Canceled" : row.purchase?.status ?? "",
qty: row.quantity ?? 0,
lineTotalCents: row.line_total ?? null,
status: lineStatus(row),
// Struck-through rows show what was ordered, not 0.
qty: row.is_canceled ? row.quantity ?? 0 : liveQuantity(row),
orderedQty: row.quantity ?? 0,
lineTotalCents: row.is_canceled
? row.line_total ?? null
: liveAmountCents(row),
isCanceled: Boolean(row.is_canceled),
isPartiallyCanceled: Boolean(row.is_partially_canceled),
// line-grain freshness (decision 1): the contributor row IS a line
syncedAt: row.synced_at ?? null,
sourceUpdatedAt: row.source_updated_at ?? null
Expand Down Expand Up @@ -463,12 +468,13 @@ const ItemTable = ({
</TableCell>
<TableCell>{c.rateName}</TableCell>
<TableCell>
<StatusPill
status={c.status}
label={c.status}
/>
<LineStatusPill status={c.status} />
</TableCell>
<TableCell align="right">
{c.isPartiallyCanceled
? `${c.qty} / ${c.orderedQty}`
: c.qty}
</TableCell>
<TableCell align="right">{c.qty}</TableCell>
<TableCell align="right">
{c.lineTotalCents == null
? "—"
Expand Down
74 changes: 53 additions & 21 deletions src/components/sponsors/reports/LinesManifestView.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ export const PER_PAGE_OPTIONS = [
MAX_PER_PAGE
];

// Clamped: legacy lines can have quantity 0, canceled_quantity 1.
// ByItemView imports from here, never the reverse (cycle).
export const liveQuantity = (row) => {
if (row?.is_canceled) return 0;
return Math.max(0, (row?.quantity ?? 0) - (row?.canceled_quantity ?? 0));
};

// Never prorate line_total — partial events charge floor unit price.
export const liveAmountCents = (row) => {
if (row?.line_total == null) return null;
if (row.is_canceled) return 0;
return Math.max(0, row.line_total - (row.canceled_amount ?? 0));
};

// Destination = the line's add-on (e.g. "Meeting Room T"); when absent, the
// logistics convention is the sponsor's booth — the API supplies it as
// `sponsor_booth` (the sponsor's Booth-type add-on name(s)). The muted "Booth"
Expand Down Expand Up @@ -113,6 +127,38 @@ const HEADERS = [
{ key: "col_source_updated" }
];

// Both cancellation states leave the parent order Paid, so purchase.status lies.
export const lineStatus = (row) => {
if (row?.is_canceled) return "Canceled";
if (row?.is_partially_canceled) return "partially_canceled";
return row?.purchase?.status ?? "";
};

const LABEL_KEY_BY_LINE_STATUS = {
Canceled: "sponsor_reports_page.status_canceled",
partially_canceled: "sponsor_reports_page.status_partially_canceled"
};

// Live over charged, matching the Qty cell. Finance reads this column and this is
// the only per-line money surface, so netting alone would drop the charged figure.
const lineTotalLabel = (row) =>
row.is_partially_canceled
? `${currencyAmountFromCents(
liveAmountCents(row)
)} / ${currencyAmountFromCents(row.line_total)}`
: currencyAmountFromCents(row.line_total);

// Not redundant with the strikethrough, which no CSV export carries.
export const LineStatusPill = ({ status }) => {
const labelKey = LABEL_KEY_BY_LINE_STATUS[status];
return (
<StatusPill
status={status}
label={labelKey ? T.translate(labelKey) : status}
/>
);
};

const LinesManifestView = ({
rows = [],
total = 0,
Expand Down Expand Up @@ -183,31 +229,17 @@ const LinesManifestView = ({
{formatCheckoutTime(line.purchase?.checkout_at)}
</TableCell>
<TableCell>{line.notes}</TableCell>
<TableCell align="right">{line.quantity}</TableCell>
<TableCell align="right">
{line.is_partially_canceled
? `${liveQuantity(line)} / ${line.quantity}`
: line.quantity}
</TableCell>
<TableCell>{line.rate_name}</TableCell>
<TableCell>
{/* The LINE's state, not the parent order's. A soft-canceled
line leaves its order Paid, so rendering purchase.status
printed "Paid" on a dead row — and the strikethrough that
was the only other signal does not survive CSV export. */}
{line.is_canceled ? (
<StatusPill
status="Canceled"
label={T.translate(
"sponsor_reports_page.status_canceled"
)}
/>
) : (
<StatusPill
status={line.purchase?.status}
label={line.purchase?.status}
/>
)}
<LineStatusPill status={lineStatus(line)} />
</TableCell>
<TableCell align="right">
{line.line_total == null
? "—"
: currencyAmountFromCents(line.line_total)}
{line.line_total == null ? "—" : lineTotalLabel(line)}
</TableCell>
<TableCell>
{formatCheckoutTime(line.synced_at)}
Expand Down
3 changes: 2 additions & 1 deletion src/components/sponsors/reports/StatusPill.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const TONE_BY_STATUS = {
in_progress: "info",
not_applicable: "default",
canceled: "default",
cancelled: "default"
cancelled: "default",
partially_canceled: "warning"
};

export const statusTone = (status) =>
Expand Down
123 changes: 119 additions & 4 deletions src/components/sponsors/reports/__tests__/ByItemView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const line = (over = {}) => ({
notes: "dock B",
is_canceled: false,
canceled_at: null,
canceled_quantity: 0,
canceled_amount: 0,
is_partially_canceled: false,
...over
});

Expand Down Expand Up @@ -162,8 +165,10 @@ describe("groupLinesBySponsorItem", () => {
rateName: "Early",
status: "Canceled",
qty: 2,
orderedQty: 2,
lineTotalCents: 100000,
isCanceled: true,
isPartiallyCanceled: false,
syncedAt: null,
sourceUpdatedAt: null
});
Expand Down Expand Up @@ -253,6 +258,73 @@ describe("groupLinesBySponsorItem", () => {
});
});

describe("partial cancellation", () => {
const partial = (over = {}) =>
line({
quantity: 5,
canceled_quantity: 2,
canceled_amount: 40000,
is_canceled: false,
is_partially_canceled: true,
canceled_at: null,
...over
});

it("keeps a fully cancelled line out of both aggregates", () => {
const [group] = groupLinesBySponsorItem([
partial({
quantity: 5,
canceled_quantity: 5,
line_total: 100000,
is_canceled: true,
is_partially_canceled: false
})
]);
expect(group.items[0].qty).toBe(0);
expect(group.items[0].totalCents).toBeNull();
});

it("counts only live units and flags the contributor rather than Paid", () => {
const [group] = groupLinesBySponsorItem([partial()]);
expect(group.items[0].qty).toBe(3);
const [contributor] = group.items[0].contributors;
expect(contributor.isPartiallyCanceled).toBe(true);
expect(contributor.isCanceled).toBe(false);
expect(contributor.status).toBe("partially_canceled");
expect(contributor.qty).toBe(3);
expect(contributor.orderedQty).toBe(5);
});

it("still counts the parent order as an order for the item", () => {
const [group] = groupLinesBySponsorItem([partial()]);
expect(group.items[0].orders).toBe(1);
});

it("nets the cancelled money off the item total and its contributor alike", () => {
const [group] = groupLinesBySponsorItem([
partial({ line_total: 100000, canceled_amount: 40000 })
]);
expect(group.items[0].totalCents).toBe(60000);
expect(group.items[0].contributors[0].lineTotalCents).toBe(60000);
});

it("keeps an all-null-money item at null so it still renders as unknown", () => {
const [group] = groupLinesBySponsorItem([
partial({ line_total: null, canceled_amount: 0 })
]);
expect(group.items[0].totalCents).toBeNull();
});

it("keeps a fully cancelled contributor at its ordered units and charged money", () => {
const [group] = groupLinesBySponsorItem([
line({ quantity: 5, line_total: 100000, is_canceled: true })
]);
const [contributor] = group.items[0].contributors;
expect(contributor.qty).toBe(5);
expect(contributor.lineTotalCents).toBe(100000);
});
});

describe("groupLinesByItem", () => {
it("merges an item_code ACROSS sponsors into one row and names each sponsor in the drill-down", () => {
const rows = [
Expand Down Expand Up @@ -356,8 +428,10 @@ const item = (over = {}) => ({
rateName: "Early",
status: "Paid",
qty: 3,
orderedQty: 3,
lineTotalCents: 150000,
isCanceled: false
isCanceled: false,
isPartiallyCanceled: false
},
{
sponsorName: "Nvidia",
Expand All @@ -366,10 +440,12 @@ const item = (over = {}) => ({
addOnName: null,
checkoutAt: null,
rateName: "Standard",
status: "Pending Payment",
status: "Canceled",
qty: 2,
orderedQty: 2,
lineTotalCents: 100000,
isCanceled: true
isCanceled: true,
isPartiallyCanceled: false
}
],
...over
Expand Down Expand Up @@ -463,7 +539,9 @@ describe("ByItemView", () => {
]
});
fireEvent.click(screen.getByText("AV1")); // expand the item
expect(screen.getByText("Canceled")).toBeInTheDocument();
expect(
screen.getByText("sponsor_reports_page.status_canceled")
).toBeInTheDocument();
const syncedText = moment.unix(synced).utc().format("YYYY-MM-DD h:mm A");
const sourceUpdatedText = moment
.unix(sourceUpdated)
Expand All @@ -475,6 +553,43 @@ describe("ByItemView", () => {
expect(cells[cells.length - 1]).toHaveTextContent(sourceUpdatedText);
});

it("renders a partially cancelled contributor's split quantity and pill (regression: catches the isPartiallyCanceled render branch being dropped)", () => {
renderView({
groups: [
group({
items: [
item({
contributors: [
{
sponsorName: "FNTECH",
number: "OCP-1",
formCode: "AV",
addOnName: null,
checkoutAt: null,
rateName: "Early",
status: "partially_canceled",
qty: 3,
orderedQty: 5,
lineTotalCents: 60000,
isCanceled: false,
isPartiallyCanceled: true,
syncedAt: null,
sourceUpdatedAt: null
}
]
})
]
})
]
});
fireEvent.click(screen.getByText("AV1"));
expect(
screen.getByText("sponsor_reports_page.status_partially_canceled")
).toBeInTheDocument();
const row = screen.getByText("OCP-1").closest("tr");
expect(within(row).getByText("3 / 5")).toBeInTheDocument();
});

it("expand button toggles the drill-down and reflects aria-expanded", () => {
renderView();
const toggle = screen.getByRole("button", {
Expand Down
Loading
Loading