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"));