mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
16
autogpt_platform/backend/backend/server/v2/admin/model.py
Normal file
16
autogpt_platform/backend/backend/server/v2/admin/model.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
autogpt_platform/frontend/src/app/admin/spending/actions.ts
Normal file
45
autogpt_platform/frontend/src/app/admin/spending/actions.ts
Normal 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;
|
||||
}
|
||||
58
autogpt_platform/frontend/src/app/admin/spending/page.tsx
Normal file
58
autogpt_platform/frontend/src/app/admin/spending/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user