feat(frontend,backend): user spending admin dashboard (#9751)

<!-- Clearly explain the need for these changes: -->
We need a way to refund people who spend money on agents wihout making
manual db actions

### Changes 🏗️
- Adds a bunch for refunding users
- Adds reasons and admin id for actions
- Add admin to db manager
- Add UI for this for the admin panel
- Clean up pagination controls
<!-- Concisely describe all of the changes made in this pull request:
-->

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [x] Test by importing dev db as baseline
- [x] Add transactions on top for "refund", and make sure all existing
transactions work

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
This commit is contained in:
Nicholas Tindle
2025-04-29 12:39:25 -05:00
committed by GitHub
parent 9fa62c03f6
commit 04c4340ee3
19 changed files with 821 additions and 28 deletions

View File

@@ -3,6 +3,7 @@ import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from datetime import datetime, timezone
from typing import cast
import stripe
from autogpt_libs.utils.cache import thread_cached
@@ -27,13 +28,16 @@ from backend.data.cost import BlockCost
from backend.data.model import (
AutoTopUpConfig,
RefundRequest,
TopUpType,
TransactionHistory,
UserTransaction,
)
from backend.data.notifications import NotificationEventDTO, RefundRequestData
from backend.data.user import get_user_by_id
from backend.data.user import get_user_by_id, get_user_email_by_id
from backend.executor.utils import UsageTransactionMetadata
from backend.notifications import NotificationManager
from backend.server.model import Pagination
from backend.server.v2.admin.model import UserHistoryResponse
from backend.util.exceptions import InsufficientBalanceError
from backend.util.retry import func_retry
from backend.util.service import get_service_client
@@ -405,6 +409,7 @@ class UserCredit(UserCreditBase):
# Avoid multiple auto top-ups within the same graph execution.
key=f"AUTO-TOP-UP-{user_id}-{metadata.graph_exec_id}",
ceiling_balance=auto_top_up.threshold,
top_up_type=TopUpType.AUTO,
)
except Exception as e:
# Failed top-up is not critical, we can move on.
@@ -414,8 +419,15 @@ class UserCredit(UserCreditBase):
return balance
async def top_up_credits(self, user_id: str, amount: int):
await self._top_up_credits(user_id, amount)
async def top_up_credits(
self,
user_id: str,
amount: int,
top_up_type: TopUpType = TopUpType.UNCATEGORIZED,
):
await self._top_up_credits(
user_id=user_id, amount=amount, top_up_type=top_up_type
)
async def onboarding_reward(self, user_id: str, credits: int, step: OnboardingStep):
key = f"REWARD-{user_id}-{step.value}"
@@ -598,7 +610,7 @@ class UserCredit(UserCreditBase):
evidence_text += (
f"- {tx.description}: Amount ${tx.amount / 100:.2f} on {tx.transaction_time.isoformat()}, "
f"resulting balance ${tx.balance / 100:.2f} {additional_comment}\n"
f"resulting balance ${tx.running_balance / 100:.2f} {additional_comment}\n"
)
evidence_text += (
"\nThis evidence demonstrates that the transaction was authorized and that the charged amount was used to render the service as agreed."
@@ -617,7 +629,24 @@ class UserCredit(UserCreditBase):
amount: int,
key: str | None = None,
ceiling_balance: int | None = None,
top_up_type: TopUpType = TopUpType.UNCATEGORIZED,
metadata: dict | None = None,
):
# init metadata, without sharing it with the world
metadata = metadata or {}
if not metadata["reason"]:
match top_up_type:
case TopUpType.MANUAL:
metadata["reason"] = {"reason": f"Top up credits for {user_id}"}
case TopUpType.AUTO:
metadata["reason"] = {
"reason": f"Auto top up credits for {user_id}"
}
case _:
metadata["reason"] = {
"reason": f"Top up reason unknown for {user_id}"
}
if amount < 0:
raise ValueError(f"Top up amount must not be negative: {amount}")
@@ -640,6 +669,7 @@ class UserCredit(UserCreditBase):
is_active=False,
transaction_key=key,
ceiling_balance=ceiling_balance,
metadata=(Json(metadata)),
)
customer_id = await get_stripe_customer_id(user_id)
@@ -823,8 +853,9 @@ class UserCredit(UserCreditBase):
take=transaction_count_limit,
)
# doesn't fill current_balance, reason, user_email, admin_email, or extra_data
grouped_transactions: dict[str, UserTransaction] = defaultdict(
lambda: UserTransaction()
lambda: UserTransaction(user_id=user_id)
)
tx_time = None
for t in transactions:
@@ -854,7 +885,7 @@ class UserCredit(UserCreditBase):
if tx_time > gt.transaction_time:
gt.transaction_time = tx_time
gt.balance = t.runningBalance or 0
gt.running_balance = t.runningBalance or 0
return TransactionHistory(
transactions=list(grouped_transactions.values()),
@@ -904,6 +935,7 @@ class BetaUserCredit(UserCredit):
amount=max(self.num_user_credits_refill - balance, 0),
transaction_type=CreditTransactionType.GRANT,
transaction_key=f"MONTHLY-CREDIT-TOP-UP-{cur_time}",
metadata=Json({"reason": "Monthly credit refill"}),
)
return balance
except UniqueViolationError:
@@ -991,3 +1023,81 @@ async def get_auto_top_up(user_id: str) -> AutoTopUpConfig:
return AutoTopUpConfig(threshold=0, amount=0)
return AutoTopUpConfig.model_validate(user.topUpConfig)
async def admin_get_user_history(
page: int = 1,
page_size: int = 20,
search: str | None = None,
transaction_filter: CreditTransactionType | None = None,
) -> UserHistoryResponse:
if page < 1 or page_size < 1:
raise ValueError("Invalid pagination input")
where_clause: CreditTransactionWhereInput = {}
if transaction_filter:
where_clause["type"] = transaction_filter
if search:
where_clause["OR"] = [
{"userId": {"contains": search, "mode": "insensitive"}},
{"User": {"is": {"email": {"contains": search, "mode": "insensitive"}}}},
{"User": {"is": {"name": {"contains": search, "mode": "insensitive"}}}},
]
transactions = await CreditTransaction.prisma().find_many(
where=where_clause,
skip=(page - 1) * page_size,
take=page_size,
include={"User": True},
order={"createdAt": "desc"},
)
total = await CreditTransaction.prisma().count(where=where_clause)
total_pages = (total + page_size - 1) // page_size
history = []
for tx in transactions:
admin_id = ""
admin_email = ""
reason = ""
metadata: dict = cast(dict, tx.metadata) or {}
if metadata:
admin_id = metadata.get("admin_id")
admin_email = (
(await get_user_email_by_id(admin_id) or f"Unknown Admin: {admin_id}")
if admin_id
else ""
)
reason = metadata.get("reason", "No reason provided")
balance, last_update = await get_user_credit_model()._get_credits(tx.userId)
history.append(
UserTransaction(
transaction_key=tx.transactionKey,
transaction_time=tx.createdAt,
transaction_type=tx.type,
amount=tx.amount,
current_balance=balance,
running_balance=tx.runningBalance or 0,
user_id=tx.userId,
user_email=(
tx.User.email
if tx.User
else (await get_user_by_id(tx.userId)).email
),
reason=reason,
admin_email=admin_email,
extra_data=str(metadata),
)
)
return UserHistoryResponse(
history=history,
pagination=Pagination(
total_items=total,
total_pages=total_pages,
current_page=page,
page_size=page_size,
),
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import enum
import logging
from collections import defaultdict
from datetime import datetime, timezone
@@ -449,6 +450,12 @@ class ContributorDetails(BaseModel):
name: str = Field(title="Name", description="The name of the contributor.")
class TopUpType(enum.Enum):
AUTO = "AUTO"
MANUAL = "MANUAL"
UNCATEGORIZED = "UNCATEGORIZED"
class AutoTopUpConfig(BaseModel):
amount: int
"""Amount of credits to top up."""
@@ -461,12 +468,18 @@ class UserTransaction(BaseModel):
transaction_time: datetime = datetime.min.replace(tzinfo=timezone.utc)
transaction_type: CreditTransactionType = CreditTransactionType.USAGE
amount: int = 0
balance: int = 0
running_balance: int = 0
current_balance: int = 0
description: str | None = None
usage_graph_id: str | None = None
usage_execution_id: str | None = None
usage_node_count: int = 0
usage_start_time: datetime = datetime.max.replace(tzinfo=timezone.utc)
user_id: str
user_email: str | None = None
reason: str | None = None
admin_email: str | None = None
extra_data: str | None = None
class TransactionHistory(BaseModel):

View File

@@ -617,6 +617,7 @@ class Executor:
block_id=node_exec.block_id,
block=block.name,
input=matching_filter,
reason=f"Ran block {node_exec.block_id} {block.name}",
),
)
execution_stats.cost += cost
@@ -633,6 +634,7 @@ class Executor:
"execution_count": execution_count,
"charge": "Execution Cost",
},
reason=f"Execution Cost for ex_id:{node_exec.graph_exec_id} g_id:{node_exec.graph_id}",
),
)
execution_stats.cost += cost

View File

@@ -99,6 +99,7 @@ class UsageTransactionMetadata(BaseModel):
block_id: str | None = None
block: str | None = None
input: BlockInput | None = None
reason: str | None = None
def execution_usage_cost(execution_count: int) -> tuple[int, int]:

View File

@@ -19,6 +19,7 @@ import backend.data.graph
import backend.data.user
import backend.server.routers.postmark.postmark
import backend.server.routers.v1
import backend.server.v2.admin.credit_admin_routes
import backend.server.v2.admin.store_admin_routes
import backend.server.v2.library.db
import backend.server.v2.library.model
@@ -107,6 +108,11 @@ app.include_router(
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router(
backend.server.v2.admin.credit_admin_routes.router,
tags=["v2", "admin"],
prefix="/api/credits",
)
app.include_router(
backend.server.v2.library.routes.router, tags=["v2"], prefix="/api/library"
)

View File

@@ -0,0 +1,77 @@
import logging
import typing
from autogpt_libs.auth import requires_admin_user
from autogpt_libs.auth.depends import get_user_id
from fastapi import APIRouter, Body, Depends
from prisma import Json
from prisma.enums import CreditTransactionType
from backend.data.credit import admin_get_user_history, get_user_credit_model
from backend.server.v2.admin.model import AddUserCreditsResponse, UserHistoryResponse
logger = logging.getLogger(__name__)
_user_credit_model = get_user_credit_model()
router = APIRouter(
prefix="/admin",
tags=["credits", "admin"],
dependencies=[Depends(requires_admin_user)],
)
@router.post("/add_credits", response_model=AddUserCreditsResponse)
async def add_user_credits(
user_id: typing.Annotated[str, Body()],
amount: typing.Annotated[int, Body()],
comments: typing.Annotated[str, Body()],
admin_user: typing.Annotated[
str,
Depends(get_user_id),
],
):
""" """
logger.info(f"Admin user {admin_user} is adding {amount} credits to user {user_id}")
new_balance, transaction_key = await _user_credit_model._add_transaction(
user_id,
amount,
transaction_type=CreditTransactionType.GRANT,
metadata=Json({"admin_id": admin_user, "reason": comments}),
)
return {
"new_balance": new_balance,
"transaction_key": transaction_key,
}
@router.get(
"/users_history",
response_model=UserHistoryResponse,
)
async def admin_get_all_user_history(
admin_user: typing.Annotated[
str,
Depends(get_user_id),
],
search: typing.Optional[str] = None,
page: int = 1,
page_size: int = 20,
transaction_filter: typing.Optional[CreditTransactionType] = None,
):
""" """
logger.info(f"Admin user {admin_user} is getting grant history")
try:
resp = await admin_get_user_history(
page=page,
page_size=page_size,
search=search,
transaction_filter=transaction_filter,
)
logger.info(f"Admin user {admin_user} got {len(resp.history)} grant history")
return resp
except Exception as e:
logger.exception(f"Error getting grant history: {e}")
raise e

View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel
from backend.data.model import UserTransaction
from backend.server.model import Pagination
class UserHistoryResponse(BaseModel):
"""Response model for listings with version history"""
history: list[UserTransaction]
pagination: Pagination
class AddUserCreditsResponse(BaseModel):
new_balance: int
transaction_key: str

View File

@@ -793,6 +793,7 @@ async def create_store_version(
changes_summary=changes_summary,
version=next_version,
)
except prisma.errors.PrismaError as e:
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create new store version"

View File

@@ -4,20 +4,7 @@ from typing import List
import prisma.enums
import pydantic
class Pagination(pydantic.BaseModel):
total_items: int = pydantic.Field(
description="Total number of items.", examples=[42]
)
total_pages: int = pydantic.Field(
description="Total number of pages.", examples=[97]
)
current_page: int = pydantic.Field(
description="Current_page page number.", examples=[1]
)
page_size: int = pydantic.Field(
description="Number of items per page.", examples=[25]
)
from backend.server.model import Pagination
class MyAgent(pydantic.BaseModel):

View File

@@ -46,6 +46,7 @@ async def spend_credits(entry: NodeExecutionEntry) -> int:
block_id=entry.block_id,
block=entry.block_id,
input=matching_filter,
reason=f"Ran block {entry.block_id} {block.name}",
),
)

View File

@@ -298,7 +298,9 @@ export default function CreditsPage() {
>
<b>{formatCredits(transaction.amount)}</b>
</TableCell>
<TableCell>{formatCredits(transaction.balance)}</TableCell>
<TableCell>
{formatCredits(transaction.running_balance)}
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import BackendApi from "@/lib/autogpt-server-api";
import {
UsersBalanceHistoryResponse,
CreditTransactionType,
} from "@/lib/autogpt-server-api/types";
export async function addDollars(formData: FormData) {
const data = {
user_id: formData.get("id") as string,
amount: parseInt(formData.get("amount") as string),
comments: formData.get("comments") as string,
};
const api = new BackendApi();
const resp = await api.addUserCredits(
data.user_id,
data.amount,
data.comments,
);
console.log(resp);
revalidatePath("/admin/spending");
}
export async function getUsersTransactionHistory(
page: number = 1,
pageSize: number = 20,
search?: string,
transactionType?: CreditTransactionType,
): Promise<UsersBalanceHistoryResponse> {
const data: Record<string, any> = {
page,
page_size: pageSize,
};
if (search) {
data.search = search;
}
if (transactionType) {
data.transaction_filter = transactionType;
}
const api = new BackendApi();
const history = await api.getUsersHistory(data);
return history;
}

View File

@@ -0,0 +1,58 @@
import { AdminUserGrantHistory } from "@/components/admin/spending/admin-grant-history-data-table";
import type { CreditTransactionType } from "@/lib/autogpt-server-api";
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
function SpendingDashboard({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
const page = searchParams.page ? Number.parseInt(searchParams.page) : 1;
const search = searchParams.search;
const status = searchParams.status as CreditTransactionType | undefined;
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">User Spending</h1>
<p className="text-gray-500">Manage user spending balances</p>
</div>
</div>
<Suspense
fallback={
<div className="py-10 text-center">Loading submissions...</div>
}
>
<AdminUserGrantHistory
initialPage={page}
initialStatus={status}
initialSearch={search}
/>
</Suspense>
</div>
</div>
);
}
export default async function SpendingDashboardPage({
searchParams,
}: {
searchParams: {
page?: string;
status?: string;
search?: string;
};
}) {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedSpendingDashboard = await withAdminAccess(SpendingDashboard);
return <ProtectedSpendingDashboard searchParams={searchParams} />;
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { useRouter } from "next/navigation";
import { addDollars } from "@/app/admin/spending/actions";
import useCredits from "@/hooks/useCredits";
export function AdminAddMoneyButton({
userId,
userEmail,
currentBalance,
defaultAmount,
defaultComments,
}: {
userId: string;
userEmail: string;
currentBalance: number;
defaultAmount?: number;
defaultComments?: string;
}) {
const router = useRouter();
const [isAddMoneyDialogOpen, setIsAddMoneyDialogOpen] = useState(false);
const [dollarAmount, setDollarAmount] = useState(
defaultAmount ? Math.abs(defaultAmount / 100).toFixed(2) : "1.00",
);
const { formatCredits } = useCredits();
const handleApproveSubmit = async (formData: FormData) => {
setIsAddMoneyDialogOpen(false);
try {
await addDollars(formData);
router.refresh(); // Refresh the current route
} catch (error) {
console.error("Error adding dollars:", error);
}
};
return (
<>
<Button
size="sm"
variant="default"
onClick={(e) => {
e.stopPropagation();
setIsAddMoneyDialogOpen(true);
}}
>
Add Dollars
</Button>
{/* Add $$$ Dialog */}
<Dialog
open={isAddMoneyDialogOpen}
onOpenChange={setIsAddMoneyDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Dollars</DialogTitle>
<DialogDescription className="pt-2">
<div className="mb-2">
<span className="font-medium">User:</span> {userEmail}
</div>
<div>
<span className="font-medium">Current balance:</span> $
{(currentBalance / 100).toFixed(2)}
</div>
</DialogDescription>
</DialogHeader>
<form action={handleApproveSubmit}>
<input type="hidden" name="id" value={userId} />
<input
type="hidden"
name="amount"
value={formatCredits(Number(dollarAmount))}
/>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="dollarAmount">Amount (in dollars)</Label>
<div className="flex">
<div className="flex items-center justify-center rounded-l-md border border-r-0 bg-gray-50 px-3 text-gray-500">
$
</div>
<Input
id="dollarAmount"
type="number"
step="0.01"
min="0"
className="rounded-l-none"
value={dollarAmount}
onChange={(e) => setDollarAmount(e.target.value)}
placeholder="0.00"
/>
</div>
</div>
</div>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="comments">Comments (Optional)</Label>
<Textarea
id="comments"
name="comments"
placeholder="Why are you adding dollars?"
defaultValue={defaultComments || "We love you!"}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsAddMoneyDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit">Add Dollars</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,183 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PaginationControls } from "../../ui/pagination-controls";
import { SearchAndFilterAdminSpending } from "./search-filter-form";
import { getUsersTransactionHistory } from "@/app/admin/spending/actions";
import { AdminAddMoneyButton } from "./add-money-button";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
export async function AdminUserGrantHistory({
initialPage = 1,
initialStatus,
initialSearch,
}: {
initialPage?: number;
initialStatus?: CreditTransactionType;
initialSearch?: string;
}) {
// Server-side data fetching
const { history, pagination } = await getUsersTransactionHistory(
initialPage,
15,
initialSearch,
initialStatus,
);
// Helper function to format the amount with color based on transaction type
const formatAmount = (amount: number, type: CreditTransactionType) => {
const isPositive = type === CreditTransactionType.GRANT;
const isNeutral = type === CreditTransactionType.TOP_UP;
const color = isPositive
? "text-green-600"
: isNeutral
? "text-blue-600"
: "text-red-600";
return <span className={color}>${Math.abs(amount / 100)}</span>;
};
// Helper function to format the transaction type with color
const formatType = (type: CreditTransactionType) => {
const isGrant = type === CreditTransactionType.GRANT;
const isPurchased = type === CreditTransactionType.TOP_UP;
const isSpent = type === CreditTransactionType.USAGE;
let displayText = type;
let bgColor = "";
if (isGrant) {
bgColor = "bg-green-100 text-green-800";
} else if (isPurchased) {
bgColor = "bg-blue-100 text-blue-800";
} else if (isSpent) {
bgColor = "bg-red-100 text-red-800";
}
return (
<span className={`rounded-full px-2 py-1 text-xs font-medium ${bgColor}`}>
{displayText.valueOf()}
</span>
);
};
// Helper function to format the date
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
}).format(new Date(date));
};
return (
<div className="space-y-4">
<SearchAndFilterAdminSpending
initialStatus={initialStatus}
initialSearch={initialSearch}
/>
<div className="rounded-md border bg-white">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium">User</TableHead>
<TableHead className="font-medium">Type</TableHead>
<TableHead className="font-medium">Date</TableHead>
<TableHead className="font-medium">Reason</TableHead>
<TableHead className="font-medium">Admin</TableHead>
<TableHead className="font-medium">Starting Balance</TableHead>
<TableHead className="font-medium">Amount</TableHead>
<TableHead className="font-medium">Ending Balance</TableHead>
{/* <TableHead className="font-medium">Current Balance</TableHead> */}
<TableHead className="text-right font-medium">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="py-10 text-center text-gray-500"
>
No transactions found
</TableCell>
</TableRow>
) : (
history.map((transaction) => (
<TableRow
key={transaction.user_id}
className="hover:bg-gray-50"
>
<TableCell className="font-medium">
{transaction.user_email}
</TableCell>
<TableCell>
{formatType(transaction.transaction_type)}
</TableCell>
<TableCell className="text-gray-600">
{formatDate(transaction.transaction_time)}
</TableCell>
<TableCell>{transaction.reason}</TableCell>
<TableCell className="text-gray-600">
{transaction.admin_email}
</TableCell>
<TableCell className="font-medium text-green-600">
${(transaction.running_balance + -transaction.amount) / 100}
</TableCell>
<TableCell>
{formatAmount(
transaction.amount,
transaction.transaction_type,
)}
</TableCell>
<TableCell className="font-medium text-green-600">
${transaction.running_balance / 100}
</TableCell>
{/* <TableCell className="font-medium text-green-600">
${transaction.current_balance / 100}
</TableCell> */}
<TableCell className="text-right">
<AdminAddMoneyButton
userId={transaction.user_id}
userEmail={
transaction.user_email ?? "User Email wasn't attached"
}
currentBalance={transaction.current_balance}
defaultAmount={
transaction.transaction_type ===
CreditTransactionType.USAGE
? -transaction.amount
: undefined
}
defaultComments={
transaction.transaction_type ===
CreditTransactionType.USAGE
? "Refund for usage"
: undefined
}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<PaginationControls
currentPage={initialPage}
totalPages={pagination.total_pages}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import { CreditTransactionType } from "@/lib/autogpt-server-api";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function SearchAndFilterAdminSpending({
initialStatus,
initialSearch,
}: {
initialStatus?: CreditTransactionType;
initialSearch?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Initialize state from URL parameters
const [searchQuery, setSearchQuery] = useState(initialSearch || "");
const [selectedStatus, setSelectedStatus] = useState<string>(
searchParams.get("status") || "ALL",
);
// Update local state when URL parameters change
useEffect(() => {
const status = searchParams.get("status");
setSelectedStatus(status || "ALL");
setSearchQuery(searchParams.get("search") || "");
}, [searchParams]);
const handleSearch = () => {
const params = new URLSearchParams(searchParams.toString());
if (searchQuery) {
params.set("search", searchQuery);
} else {
params.delete("search");
}
if (selectedStatus !== "ALL") {
params.set("status", selectedStatus);
} else {
params.delete("status");
}
params.set("page", "1"); // Reset to first page on new search
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex items-center justify-between">
<div className="flex w-full items-center gap-2">
<Input
placeholder="Search users by Name or Email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button variant="outline" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<Select
value={selectedStatus}
onValueChange={(value) => {
setSelectedStatus(value);
const params = new URLSearchParams(searchParams.toString());
if (value === "ALL") {
params.delete("status");
} else {
params.set("status", value);
}
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`);
}}
>
<SelectTrigger className="w-1/4">
<SelectValue placeholder="Select Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value={CreditTransactionType.TOP_UP}>Top Up</SelectItem>
<SelectItem value={CreditTransactionType.USAGE}>Usage</SelectItem>
<SelectItem value={CreditTransactionType.REFUND}>Refund</SelectItem>
<SelectItem value={CreditTransactionType.GRANT}>Grant</SelectItem>
<SelectItem value={CreditTransactionType.CARD_CHECK}>
Card Check
</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -6,9 +6,11 @@ import { Button } from "@/components/ui/button";
export function PaginationControls({
currentPage,
totalPages,
pathParam = "page",
}: {
currentPage: number;
totalPages: number;
pathParam?: string;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -16,7 +18,7 @@ export function PaginationControls({
const createPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
params.set(pathParam, page.toString());
return `${pathname}?${params.toString()}`;
};

View File

@@ -53,6 +53,8 @@ import {
UserOnboarding,
ReviewSubmissionRequest,
SubmissionStatus,
AddUserCreditsResponse,
UsersBalanceHistoryResponse,
CredentialsMetaInput,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
@@ -539,9 +541,9 @@ export default class BackendAPI {
return this._get(url);
}
////////////////////////////////////////
////////////// Admin API ///////////////
////////////////////////////////////////
/////////////////////////////////////////
/////////// Admin API ///////////////////
/////////////////////////////////////////
getAdminListingsWithVersions(params?: {
status?: SubmissionStatus;
@@ -563,6 +565,26 @@ export default class BackendAPI {
);
}
addUserCredits(
user_id: string,
amount: number,
comments: string,
): Promise<AddUserCreditsResponse> {
return this._request("POST", "/credits/admin/add_credits", {
user_id,
amount,
comments,
});
}
getUsersHistory(params?: {
search?: string;
page?: number;
page_size?: number;
}): Promise<UsersBalanceHistoryResponse> {
return this._get("/credits/admin/users_history", params);
}
downloadStoreAgentAdmin(storeListingVersionId: string): Promise<BlobPart> {
const url = `/store/admin/submissions/download/${storeListingVersionId}`;

View File

@@ -776,14 +776,20 @@ export interface CreateAPIKeyResponse {
export interface CreditTransaction {
transaction_key: string;
transaction_time: Date;
transaction_type: string;
transaction_type: CreditTransactionType;
amount: number;
balance: number;
running_balance: number;
current_balance: number;
description: string;
usage_graph_id: GraphID;
usage_execution_id: GraphExecutionID;
usage_node_count: number;
usage_starting_time: Date;
user_id: string;
user_email: string | null;
reason: string | null;
admin_email: string | null;
extra_data: string | null;
}
export interface TransactionHistory {
@@ -899,6 +905,23 @@ export type AdminPendingSubmissionsRequest = {
page_size: number;
};
export enum CreditTransactionType {
TOP_UP = "TOP_UP",
USAGE = "USAGE",
GRANT = "GRANT",
REFUND = "REFUND",
CARD_CHECK = "CARD_CHECK",
}
export type UsersBalanceHistoryResponse = {
history: CreditTransaction[];
pagination: Pagination;
};
export type AddUserCreditsResponse = {
new_balance: number;
transaction_key: string;
};
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
date: DataType.DATE,
time: DataType.TIME,