mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
feat(ui): customer detail as query state, edit customer dialog, lint fixes
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
6ac635840a
commit
28f9c8746d
4 changed files with 90 additions and 22 deletions
|
|
@ -1,26 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { $api } from "@/lib/http/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
|
||||
export default function CreateCustomerDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (o: boolean) => void; onCreated: () => void }) {
|
||||
const router = useRouter();
|
||||
export default function CreateCustomerDialog({ open, onOpenChange, onCreated }: { open: boolean; onOpenChange: (o: boolean) => void; onCreated: (userId: string) => void }) {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [alias, setAlias] = useState("");
|
||||
const [budget, setBudget] = useState("");
|
||||
const create = $api.useMutation("post", "/customer/new", {
|
||||
onSuccess: () => {
|
||||
toast.success(`Created ${alias || userId}`);
|
||||
onCreated();
|
||||
onCreated(userId.trim());
|
||||
onOpenChange(false);
|
||||
router.push(uiHref(`customers/${encodeURIComponent(userId)}`));
|
||||
},
|
||||
onError: (e: unknown) => toast.error(`Failed to create customer: ${e instanceof Error ? e.message : String(e)}`),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Ban, Pencil, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -10,10 +8,10 @@ import { $api } from "@/lib/http/api";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { customerStatus, formatUSD, useCustomerSpendByCustomer } from "../_components/customerPortalData";
|
||||
import { customerLimitsOnKey, useAllKeys, useCustomerTrafficKeys, type PortalKey } from "../_components/customerKeys";
|
||||
import KeyLimitDialog from "../_components/KeyLimitDialog";
|
||||
import { customerStatus, formatUSD, useCustomerSpendByCustomer } from "./customerPortalData";
|
||||
import { customerLimitsOnKey, useAllKeys, useCustomerTrafficKeys, type PortalKey } from "./customerKeys";
|
||||
import KeyLimitDialog from "./KeyLimitDialog";
|
||||
import EditCustomerDialog from "./EditCustomerDialog";
|
||||
|
||||
const STATUS_BADGE = {
|
||||
blocked: { variant: "outline", label: "Blocked" },
|
||||
|
|
@ -21,15 +19,14 @@ const STATUS_BADGE = {
|
|||
active: { variant: "secondary", label: "Active" },
|
||||
} as const;
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const customerId = decodeURIComponent(params.id);
|
||||
export default function CustomerDetail({ customerId, onBack }: { customerId: string; onBack: () => void }) {
|
||||
const { accessToken } = useAuthorized();
|
||||
const info = $api.useQuery("get", "/customer/info", { params: { query: { end_user_id: customerId } } }, { enabled: Boolean(accessToken) });
|
||||
const spendQuery = useCustomerSpendByCustomer(accessToken);
|
||||
const keysQuery = useAllKeys(accessToken);
|
||||
const trafficQuery = useCustomerTrafficKeys(accessToken, customerId);
|
||||
const [dialog, setDialog] = useState<{ open: boolean; editKey: PortalKey | null }>({ open: false, editKey: null });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const block = $api.useMutation("post", "/customer/block", { onSuccess: () => { toast.success("Customer blocked"); info.refetch(); } });
|
||||
const unblock = $api.useMutation("post", "/customer/update", { onSuccess: () => { toast.success("Customer unblocked"); info.refetch(); } });
|
||||
|
||||
|
|
@ -63,7 +60,7 @@ export default function CustomerDetailPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" disabled><Pencil className="size-4" /> Edit</Button>
|
||||
<Button variant="outline" disabled={!c} onClick={() => setEditOpen(true)}><Pencil className="size-4" /> Edit</Button>
|
||||
{c?.blocked ? (
|
||||
<Button variant="outline" onClick={() => unblock.mutate({ body: { user_id: customerId, blocked: false } })}><Ban className="size-4" /> Unblock</Button>
|
||||
) : (
|
||||
|
|
@ -115,7 +112,8 @@ export default function CustomerDetailPage() {
|
|||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground"><Link href={uiHref("customers")} className="hover:underline">Back to customers</Link></p>
|
||||
<Button variant="link" size="sm" className="h-auto p-0 text-xs text-muted-foreground" onClick={onBack}>Back to customers</Button>
|
||||
{c && <EditCustomerDialog open={editOpen} onOpenChange={setEditOpen} customer={c} onSaved={() => info.refetch()} />}
|
||||
{accessToken && (
|
||||
<KeyLimitDialog
|
||||
open={dialog.open}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { $api } from "@/lib/http/api";
|
||||
import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
customer: EndUser;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function EditCustomerForm({ onOpenChange, customer, onSaved }: Omit<Props, "open">) {
|
||||
const [alias, setAlias] = useState(customer.alias ?? "");
|
||||
const currentBudget = customer.litellm_budget_table?.max_budget;
|
||||
const [budget, setBudget] = useState(currentBudget === null || currentBudget === undefined ? "" : String(currentBudget));
|
||||
const update = $api.useMutation("post", "/customer/update", {
|
||||
onSuccess: () => {
|
||||
toast.success("Customer updated");
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e) => toast.error(`Failed to update customer: ${String(e)}`),
|
||||
});
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
update.mutate({
|
||||
body: {
|
||||
user_id: customer.user_id,
|
||||
alias: alias.trim() || null,
|
||||
blocked: Boolean(customer.blocked),
|
||||
max_budget: budget ? Number(budget) : null,
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit customer</DialogTitle>
|
||||
<DialogDescription><span className="font-mono text-xs">{customer.user_id}</span></DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ec-alias">Alias</Label>
|
||||
<Input id="ec-alias" value={alias} onChange={(e) => setAlias(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ec-budget">Max Budget (USD)</Label>
|
||||
<Input id="ec-budget" inputMode="decimal" value={budget} onChange={(e) => setBudget(e.target.value.replace(/[^0-9.]/g, ""))} placeholder="Leave empty for no budget" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={update.isPending}>Save</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditCustomerDialog({ open, ...form }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={form.onOpenChange}>
|
||||
{open && <EditCustomerForm {...form} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { customerLimitsOnKey, useAllKeys } from "./_components/customerKeys";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -12,7 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import CustomerDetail from "./_components/CustomerDetail";
|
||||
import CreateCustomerDialog from "./_components/CreateCustomerDialog";
|
||||
import { customerStatus, formatUSD, useCustomerSpendByCustomer, type CustomerStatus } from "./_components/customerPortalData";
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ const STATUS_LABEL: Record<CustomerStatus, { label: string; variant: "default" |
|
|||
|
||||
export default function CustomersPage() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useQueryState("id", parseAsString);
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const customersQuery = useCustomers(100, search || undefined);
|
||||
|
|
@ -48,6 +48,8 @@ export default function CustomersPage() {
|
|||
const block = $api.useMutation("post", "/customer/block", { onSuccess: () => { toast.success("Customer blocked"); customersQuery.refetch(); } });
|
||||
const unblock = $api.useMutation("post", "/customer/update", { onSuccess: () => { toast.success("Customer unblocked"); customersQuery.refetch(); } });
|
||||
|
||||
if (selectedId) return <CustomerDetail customerId={selectedId} onBack={() => setSelectedId(null)} />;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4 px-6 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
|
@ -78,7 +80,7 @@ export default function CustomersPage() {
|
|||
{rows.map(({ c, spend, keys, status }) => {
|
||||
const max = c.litellm_budget_table?.max_budget ?? null;
|
||||
return (
|
||||
<TableRow key={c.user_id} className="cursor-pointer" onClick={() => router.push(uiHref(`customers/${encodeURIComponent(c.user_id)}`))}>
|
||||
<TableRow key={c.user_id} className="cursor-pointer" onClick={() => setSelectedId(c.user_id, { history: "push" })}>
|
||||
<TableCell className="pl-4">
|
||||
<p className="font-medium">{c.alias || c.user_id}</p>
|
||||
<p className="font-mono text-[11px] text-muted-foreground">{c.user_id}</p>
|
||||
|
|
@ -100,7 +102,7 @@ export default function CustomersPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<CreateCustomerDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={() => customersQuery.refetch()} />
|
||||
<CreateCustomerDialog open={createOpen} onOpenChange={setCreateOpen} onCreated={(id) => { customersQuery.refetch(); setSelectedId(id, { history: "push" }); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue