feat(buyer-tasks): show recipient + send date on PO Sent card

Adds a small muted caption under the Re-Send / Mark as ready buttons
on the PO Sent tab showing the email address the PO was sent to and
the send timestamp (en-GB short format like '5 Aug 2026, 14:23').

Data sources come from the existing BuyerTaskGroup.emailPayload
exposed in commit f61c173 — no new PB fields, no new types. The
caption only renders when sentAt is present, so legacy rows (or a
fresh draft flipped to po_sent without a successful send) stay clean.

Visible on mail_failed too: the operator can see who we tried to
reach and when, alongside the existing 'Mail failed' banner.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 18:16:22 +02:00
parent f3052bfd40
commit d2ffbea6bc
2 changed files with 168 additions and 28 deletions

View file

@ -206,4 +206,111 @@ describe("BuyerTaskGroupCard", () => {
screen.queryByTestId(`buyer-task-po-number-${baseGroup.groupId}`),
).toBeNull();
});
describe("po_sent sent-info caption", () => {
const sentEmail = "orders@acme.co.za";
const sentAt = "2026-08-05T13:23:00.000Z";
it("renders the recipient and send date under the buttons", () => {
render(
<BuyerTaskGroupCard
group={{
...baseGroup,
step: "po_sent",
poStatus: "sent",
emailPayload: { to: sentEmail, sentAt },
}}
onAction={vi.fn()}
onResend={vi.fn()}
/>,
);
const caption = screen.getByTestId(
`buyer-task-sent-info-${baseGroup.groupId}`,
);
expect(caption).toHaveTextContent(new RegExp(sentEmail.replace(/\./g, "\\.")));
expect(caption.textContent).toContain("Sent to");
// 5 Aug 2026 — en-GB short month, exact day.
expect(caption.textContent).toMatch(/5 Aug 2026/);
// Time is rendered in 24-hour HH:mm form.
expect(caption.textContent).toMatch(/\d{2}:\d{2}/);
});
it("hides the caption when emailPayload.sentAt is missing", () => {
render(
<BuyerTaskGroupCard
group={{
...baseGroup,
step: "po_sent",
poStatus: "sent",
emailPayload: { to: sentEmail }, // no sentAt
}}
onAction={vi.fn()}
onResend={vi.fn()}
/>,
);
expect(
screen.queryByTestId(`buyer-task-sent-info-${baseGroup.groupId}`),
).toBeNull();
});
it("still renders the caption on mail_failed when sentAt is set", () => {
// The mail-failed banner explains the failure — the operator still
// wants to see who we tried and when, so the previous attempt's
// recipient/timestamp stays visible above the banner.
render(
<BuyerTaskGroupCard
group={{
...baseGroup,
step: "po_sent",
poStatus: "mail_failed",
emailError: "[webhook] 502",
emailPayload: { to: sentEmail, sentAt },
}}
onAction={vi.fn()}
onResend={vi.fn()}
/>,
);
expect(
screen.getByTestId(`buyer-task-sent-info-${baseGroup.groupId}`),
).toBeInTheDocument();
expect(screen.getByText(/mail failed/i)).toBeInTheDocument();
});
it("falls back to a bare 'Sent' label when the recipient is missing", () => {
render(
<BuyerTaskGroupCard
group={{
...baseGroup,
step: "po_sent",
poStatus: "sent",
emailPayload: { sentAt }, // no `to`
}}
onAction={vi.fn()}
onResend={vi.fn()}
/>,
);
const caption = screen.getByTestId(
`buyer-task-sent-info-${baseGroup.groupId}`,
);
expect(caption.textContent).not.toMatch(/Sent to/);
expect(caption.textContent).toMatch(/Sent\s+\d/);
});
it("does not render the caption on the po_draft tab", () => {
render(
<BuyerTaskGroupCard
group={{
...baseGroup,
step: "po_draft",
poStatus: "draft",
emailPayload: { to: sentEmail, sentAt },
}}
onAction={vi.fn()}
/>,
);
expect(
screen.queryByTestId(`buyer-task-sent-info-${baseGroup.groupId}`),
).toBeNull();
});
});
});

View file

@ -106,34 +106,67 @@ export function BuyerTaskGroupCard({
</div>
</div>
{group.step === "po_sent" ? (
// PO Sent: two side-by-side buttons. Re-Send opens the resend modal
// (reuses the SendPoModal wired up for po_draft on the parent page).
// Mark as ready opens the MarkReadyDialog. The mail_failed override
// now lands on the Re-Send button (the Mark as ready button stays
// primary — collection can proceed independently of mail state).
<div className="flex items-center gap-2">
<Button
type="button"
onClick={() => onResend?.(group)}
disabled={disabled || !onResend}
className={cn(
RESEND_DEFAULT_CLASS,
group.poStatus === "mail_failed" && RESEND_FAILED_CLASS,
)}
data-testid={`buyer-task-resend-${group.groupId}`}
data-po-status={group.poStatus ?? "none"}
>
{group.poStatus === "mail_failed" ? "Resend" : "Re-Send"}
</Button>
<Button
type="button"
onClick={() => onAction(group)}
disabled={disabled}
className={ACTION_CLASS[group.step]}
data-testid={`buyer-task-action-${group.groupId}-${group.step}`}
>
{ACTION_LABEL[group.step]}
</Button>
// PO Sent: two side-by-side buttons + a small "sent info"
// caption below them showing who we sent the PO to and when.
// Re-Send opens the resend modal (reuses the SendPoModal
// wired up for po_draft on the parent page). Mark as ready
// opens the MarkReadyDialog. The mail_failed override now
// lands on the Re-Send button (the Mark as ready button
// stays primary — collection can proceed independently of
// mail state). The caption is hidden when there's no
// recorded `sentAt` (legacy rows predating email_payload,
// or a fresh po_draft that got flipped straight to
// po_sent without a successful send).
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<Button
type="button"
onClick={() => onResend?.(group)}
disabled={disabled || !onResend}
className={cn(
RESEND_DEFAULT_CLASS,
group.poStatus === "mail_failed" && RESEND_FAILED_CLASS,
)}
data-testid={`buyer-task-resend-${group.groupId}`}
data-po-status={group.poStatus ?? "none"}
>
{group.poStatus === "mail_failed" ? "Resend" : "Re-Send"}
</Button>
<Button
type="button"
onClick={() => onAction(group)}
disabled={disabled}
className={ACTION_CLASS[group.step]}
data-testid={`buyer-task-action-${group.groupId}-${group.step}`}
>
{ACTION_LABEL[group.step]}
</Button>
</div>
{group.emailPayload?.sentAt ? (
<div
className="text-xs text-muted-foreground text-right leading-tight"
data-testid={`buyer-task-sent-info-${group.groupId}`}
>
{group.emailPayload.to ? (
<span>
Sent to {group.emailPayload.to}
<span aria-hidden="true"> · </span>
</span>
) : (
<span>Sent </span>
)}
<span>
{new Date(group.emailPayload.sentAt).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}
</span>
</div>
) : null}
</div>
) : (
// needs_po / po_draft: single button (unchanged behaviour).