mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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.  <sub>WebM version: [trimmed.webm](https://gist.githubusercontent.com/BilalG1/f55a8c9055cd0626320fb3153371fa97/raw/trimmed.webm)</sub> ## Screenshots | Adjust dialog (positive) | Adjust dialog (negative) | | --- | --- | |  |  | | After applying changes | Entry point on item balance rows | | --- | --- | |  |  | ## 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:
parent
3cfe5c8ad3
commit
4988312133
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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"));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user