From 4988312133a6832f69d52c31a1925383cfa825ac Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Fri, 3 Jul 2026 18:12:42 -0700 Subject: [PATCH] feat(dashboard): manual item quantity adjustments from customer payments view (#1731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) WebM version: [trimmed.webm](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/trimmed.webm) ## 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) ## 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. --- .../payments/customer-payments-section.tsx | 46 +++-- .../payments/item-quantity-change-dialog.tsx | 167 ++++++++++++++++++ .../apps/implementations/admin-app-impl.ts | 16 +- .../apps/implementations/server-app-impl.ts | 10 ++ 4 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx diff --git a/apps/dashboard/src/components/payments/customer-payments-section.tsx b/apps/dashboard/src/components/payments/customer-payments-section.tsx index d1042bc5c..29dc14535 100644 --- a/apps/dashboard/src/components/payments/customer-payments-section.tsx +++ b/apps/dashboard/src/components/payments/customer-payments-section.tsx @@ -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 ( -
- {item.displayName} - - {item.quantity} - -
+ <> +
+ {item.displayName} +
+ + {item.quantity} + + setIsAdjustOpen(true)} + > + + +
+
+ + ); } diff --git a/apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx b/apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx new file mode 100644 index 000000000..2ab965c76 --- /dev/null +++ b/apps/dashboard/src/components/payments/item-quantity-change-dialog.tsx @@ -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(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 ( + + + + Cancel + + + runAsynchronouslyWithAlert(submit())} + > + Apply Change + + + )} + > +
+
+ + { + 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 ? ( + + {error} + + ) : ( + + {newQuantity == null + ? "Positive values add to the balance, negative values subtract." + : `New balance: ${props.currentQuantity} → ${newQuantity}`} + + )} +
+ +
+ + setDescription(e.target.value)} + placeholder="e.g. Support credit for outage" + disabled={isSubmitting} + size="md" + /> +
+
+
+ ); +} diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index 7f1d26189..78ecdddd1 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -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 true); + } catch (error) { + captureError("create-item-quantity-change-cache-refresh", error); + } } async refundTransaction(options: { diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts index c931ec3a0..1594e4dcc 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts @@ -1448,6 +1448,16 @@ export class _HexclaveServerAppImplIncomplete { + 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 { if ("userId" in options) { const response = Result.orThrow(await this._serverUserProductsCache.getOrWait([options.userId, options.cursor ?? null, options.limit ?? null], "write-only"));