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:
kerry 2026-11-06 22:30:01 +00:00
parent 6ac635840a
commit 28f9c8746d
4 changed files with 90 additions and 22 deletions

View file

@ -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)}`),
});

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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>
);
}