feat(dashboard): manual item quantity adjustments from customer payments view (#1731)

Adds a way for dashboard users to create a **manual item quantity
change** — positive or negative — for a single customer and a single
item, directly from the customer payments view.

**Base:** `dev` → **Head:** `feat/manual-item-quantity-change` · 4
files, +219/−10

## Demo

Full flow on a user detail page: open the Payments tab, hit the ± button
on an item balance row, apply **+5** with a description, then **−3** —
balance and transaction history update live.

![Manual item quantity change
flow](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/manual-item-quantity-change-flow.gif)

<sub>WebM version:
[trimmed.webm](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/trimmed.webm)</sub>

## Screenshots

| Adjust dialog (positive) | Adjust dialog (negative) |
| --- | --- |
| ![Positive change with live
preview](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/07-adjust-dialog-positive.png)
| ![Negative
change](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/09-adjust-dialog-negative.png)
|

| After applying changes | Entry point on item balance rows |
| --- | --- |
| ![Balance + transaction history
updated](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/10-after-negative-change.png)
| ![Item balances
card](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/06-user-payments-item-balances.png)
|

## What's new

- **`ItemQuantityChangeDialog`**
(`apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx`)
- Signed integer quantity input (`10` adds, `-5` subtracts), validated
as a non-zero whole number, with a live `New balance: X → Y` preview.
  - Optional description, stored on the transaction.
- Submits via `adminApp.createItemQuantityChange` (which passes
`allow_negative: true`, so balances may go negative — consistent with
the existing admin API semantics).
- **Entry point** — a ± icon button on every row of the *Item balances*
card in `customer-payments-section.tsx`. Since that card is shared, the
feature works on the **user detail page**, **team detail page**, and the
payments **Customers page**, i.e. all three customer types (`user` /
`team` / `custom`).
- **SDK cache fix** (`packages/template`, SDKs regenerated) —
`createItemQuantityChange` previously left the item and transaction
caches stale, so a dashboard caller would keep seeing the old quantity.
It now refreshes the affected item cache (new protected
`_refreshItemCache` helper on the server app impl, mirroring what
`item.increaseQuantity` already did) and invalidates the transactions
cache. This is what makes the balance and transaction history update
immediately after applying a change.

## Notes for reviewers

- The dialog intentionally does not expose `expiresAt` (the API supports
it) — kept the surface minimal for the first pass.
- Transaction history already knew how to render
`manual-item-quantity-change` transactions (`api_credits (+25)` detail
rows), so no changes were needed there.
- The per-row dialog receives `currentQuantity` from the same `useItem`
hook that renders the row, so the preview can never disagree with the
displayed balance.

## Test plan

- [x] `pnpm typecheck` and `pnpm lint` green
- [x] Manual e2e against the seeded Demo Project (production builds of
dashboard + backend): created a user-scoped `api_credits` item, applied
`+25`, `−10`, `+5`, `−3` on a user — balance updated live (25 → 15 → 20
→ 17), each change produced an *Item quantity change* transaction,
success/error toasts behave, invalid input (`0`, non-integers)
disables/blocks Apply
- [x] Verified the adjust button renders for user-scoped items on the
user page (team/customers pages use the identical shared component)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an item quantity adjustment dialog for customer payments, with
live preview and “Apply change” support for manual whole-number deltas
(non-zero), plus success/error toasts.
* Payment item rows now include quick-action controls to open the
adjustment dialog directly.
* **Bug Fixes**
* Quantity updates now immediately refresh the affected item details and
keep related transaction data in sync after changes.
* Improved validation to reject invalid quantity inputs and provide
clearer inline feedback during submission.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-07-03 18:12:42 -07:00 committed by GitHub
parent 3cfe5c8ad3
commit 4988312133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 228 additions and 11 deletions

View File

@ -3,6 +3,7 @@
import {
DesignBadge,
type DesignBadgeColor,
DesignButton,
DesignCard,
} from "@/components/design-components";
import { cn, Skeleton } from "@/components/ui";
@ -10,12 +11,13 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
import { UserPageMetricCard } from "@/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-metric-card";
import { UserPageTableSection } from "@/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-table-section";
import type { Icon as PhosphorIcon } from "@phosphor-icons/react";
import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, CoinsIcon, GearIcon, ProhibitIcon, QuestionIcon, ShoppingCartIcon, ShuffleIcon } from "@phosphor-icons/react";
import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, CoinsIcon, GearIcon, PlusMinusIcon, ProhibitIcon, QuestionIcon, ShoppingCartIcon, ShuffleIcon } from "@phosphor-icons/react";
import type { DataGridColumnDef } from "@hexclave/dashboard-ui-components";
import type { Transaction, TransactionEntry, TransactionType } from "@hexclave/shared/dist/interface/crud/transactions";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { Suspense, useMemo } from "react";
import { Suspense, useMemo, useState } from "react";
import type { CustomerType } from "./customer-selector";
import { ItemQuantityChangeDialog } from "./item-quantity-change-dialog";
// Re-export so existing consumers of this module keep working, while the
// canonical definition lives in customer-selector.tsx.
@ -527,6 +529,7 @@ function ItemsCard({ customerType, customerId, itemIds }: { customerType: Custom
function ItemBalanceRow({ customerType, customerId, itemId }: { customerType: CustomerType, customerId: string, itemId: string }) {
const hexclaveAdminApp = useAdminApp();
const [isAdjustOpen, setIsAdjustOpen] = useState(false);
const itemOptions = customerType === "user"
? { userId: customerId, itemId }
: customerType === "team"
@ -536,13 +539,36 @@ function ItemBalanceRow({ customerType, customerId, itemId }: { customerType: Cu
const isNegative = item.quantity < 0;
return (
<div className="flex items-center justify-between gap-3 py-1.5" title={itemId}>
<span className="truncate text-sm text-foreground">{item.displayName}</span>
<span
className={`shrink-0 text-sm font-semibold tabular-nums ${isNegative ? "text-destructive" : "text-foreground"}`}
>
{item.quantity}
</span>
</div>
<>
<div className="flex items-center justify-between gap-3 py-1" title={itemId}>
<span className="truncate text-sm text-foreground">{item.displayName}</span>
<div className="flex shrink-0 items-center gap-1">
<span
className={`text-sm font-semibold tabular-nums ${isNegative ? "text-destructive" : "text-foreground"}`}
>
{item.quantity}
</span>
<DesignButton
variant="ghost"
size="icon"
type="button"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label={`Adjust quantity of ${item.displayName}`}
onClick={() => setIsAdjustOpen(true)}
>
<PlusMinusIcon className="h-3.5 w-3.5" aria-hidden />
</DesignButton>
</div>
</div>
<ItemQuantityChangeDialog
open={isAdjustOpen}
onOpenChange={setIsAdjustOpen}
customerType={customerType}
customerId={customerId}
itemId={itemId}
itemDisplayName={item.displayName}
currentQuantity={item.quantity}
/>
</>
);
}

View File

@ -0,0 +1,167 @@
"use client";
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import {
DesignButton,
DesignDialog,
DesignDialogClose,
DesignInput,
} from "@/components/design-components";
import { Label, Typography, toast } from "@/components/ui";
import { cn } from "@/lib/utils";
import { PlusMinusIcon } from "@phosphor-icons/react";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { Result } from "@hexclave/shared/dist/utils/results";
import { useEffect, useState } from "react";
import type { CustomerType } from "./customer-selector";
type Props = {
open: boolean,
onOpenChange: (open: boolean) => void,
customerType: CustomerType,
customerId: string,
itemId: string,
itemDisplayName: string,
currentQuantity: number,
};
function parseQuantityChange(text: string): number | null {
const trimmed = text.trim();
if (!/^[+-]?\d+$/.test(trimmed)) return null;
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isSafeInteger(parsed) || parsed === 0) return null;
return parsed;
}
/**
* Dialog for creating a manual item quantity change (positive or negative
* delta) for a single customer and a single item. Opened from the item
* balances card on the customer payments views.
*/
export function ItemQuantityChangeDialog(props: Props) {
const hexclaveAdminApp = useAdminApp();
const [quantityText, setQuantityText] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset the form whenever the dialog is (re)opened so stale input doesn't
// leak across invocations.
useEffect(() => {
if (props.open) {
setQuantityText("");
setDescription("");
setError(null);
}
}, [props.open]);
const parsedQuantity = parseQuantityChange(quantityText);
const newQuantity = parsedQuantity == null ? null : props.currentQuantity + parsedQuantity;
const submit = async () => {
if (parsedQuantity == null) {
setError("Enter a non-zero whole number. Use a negative value to subtract.");
return;
}
setIsSubmitting(true);
try {
const customerOptions = props.customerType === "user"
? { userId: props.customerId }
: props.customerType === "team"
? { teamId: props.customerId }
: { customCustomerId: props.customerId };
const result = await Result.fromPromise(hexclaveAdminApp.createItemQuantityChange({
...customerOptions,
itemId: props.itemId,
quantity: parsedQuantity,
...(description.trim() ? { description: description.trim() } : {}),
}));
if (result.status === "error") {
toast({ title: "Failed to update item quantity", variant: "destructive" });
return;
}
toast({ title: `${props.itemDisplayName}: quantity changed by ${parsedQuantity > 0 ? "+" : ""}${parsedQuantity}` });
props.onOpenChange(false);
} finally {
setIsSubmitting(false);
}
};
return (
<DesignDialog
open={props.open}
onOpenChange={props.onOpenChange}
size="md"
icon={PlusMinusIcon}
title="Adjust Item Quantity"
description={`Create a manual quantity change for ${props.itemDisplayName}.`}
footer={(
<>
<DesignDialogClose asChild>
<DesignButton variant="secondary" size="sm" type="button" disabled={isSubmitting}>
Cancel
</DesignButton>
</DesignDialogClose>
<DesignButton
size="sm"
type="button"
disabled={isSubmitting || parsedQuantity == null}
loading={isSubmitting}
onClick={() => runAsynchronouslyWithAlert(submit())}
>
Apply Change
</DesignButton>
</>
)}
>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="quantity-change" className="text-sm font-medium">
Quantity change
</Label>
<DesignInput
id="quantity-change"
value={quantityText}
onChange={(e) => {
setQuantityText(e.target.value);
if (error) setError(null);
}}
placeholder="e.g. 10 or -5"
autoFocus
disabled={isSubmitting}
size="md"
className={cn(
"tabular-nums",
error && "border-destructive focus-visible:ring-destructive/30",
)}
/>
{error ? (
<Typography type="label" className="text-destructive text-xs">
{error}
</Typography>
) : (
<Typography type="label" className="text-muted-foreground text-xs">
{newQuantity == null
? "Positive values add to the balance, negative values subtract."
: `New balance: ${props.currentQuantity}${newQuantity}`}
</Typography>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="quantity-change-description" className="text-sm font-medium">
Description <span className="font-normal text-muted-foreground">(optional)</span>
</Label>
<DesignInput
id="quantity-change-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Support credit for outage"
disabled={isSubmitting}
size="md"
/>
</div>
</div>
</DesignDialog>
);
}

View File

@ -9,7 +9,7 @@ import type { AdminGetSessionReplayChunkEventsResponse } from "@hexclave/shared/
import type { Transaction, TransactionType } from "@hexclave/shared/dist/interface/crud/transactions";
import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields";
import type { MoneyAmount } from "@hexclave/shared/dist/utils/currency-constants";
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
import { HexclaveAssertionError, captureError, throwErr } from "@hexclave/shared/dist/utils/errors";
import type { Json } from "@hexclave/shared/dist/utils/json";
import { pick, typedEntries, typedValues } from "@hexclave/shared/dist/utils/objects";
import { Result } from "@hexclave/shared/dist/utils/results";
@ -905,6 +905,20 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
allow_negative: true,
}
);
const [customerType, customerId] = "userId" in options
? ["user", options.userId] as const
: "teamId" in options
? ["team", options.teamId] as const
: ["custom", options.customCustomerId] as const;
try {
// Best-effort: the quantity change is already persisted at this point, so
// a cache refresh failure must not make the mutation look failed (a retry
// would apply the delta twice).
await this._refreshItemCache(customerType, customerId, options.itemId);
await this._transactionsCache.invalidateWhere(() => true);
} catch (error) {
captureError("create-item-quantity-change-cache-refresh", error);
}
}
async refundTransaction(options: {

View File

@ -1448,6 +1448,16 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
}
}
protected async _refreshItemCache(customerType: "user" | "team" | "custom", customerId: string, itemId: string): Promise<void> {
if (customerType === "user") {
await this._serverUserItemsCache.refresh([customerId, itemId]);
} else if (customerType === "team") {
await this._serverTeamItemsCache.refresh([customerId, itemId]);
} else {
await this._serverCustomItemsCache.refresh([customerId, itemId]);
}
}
async listProducts(options: CustomerProductsRequestOptions): Promise<CustomerProductsList> {
if ("userId" in options) {
const response = Result.orThrow(await this._serverUserProductsCache.getOrWait([options.userId, options.cursor ?? null, options.limit ?? null], "write-only"));