initial budget ui on the provider

This commit is contained in:
Owen
2026-08-10 12:03:46 -04:00
parent 187936e5dd
commit 211d3a53f5
7 changed files with 676 additions and 1 deletions
@@ -0,0 +1,22 @@
"use client";
import { SettingsContainer } from "@app/components/Settings";
import { BudgetsEditor } from "@app/components/BudgetsEditor";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useTranslations } from "next-intl";
export default function AiProviderBudgetPage() {
const { provider } = useAiProviderContext();
const t = useTranslations();
return (
<SettingsContainer>
<BudgetsEditor
orgId={provider.orgId}
scope={{ type: "provider", id: provider.providerId }}
title={t("aiProviderBudgetSettings")}
description={t("aiProviderBudgetSettingsDescription")}
/>
</SettingsContainer>
);
}
@@ -76,6 +76,10 @@ export default async function AiProviderLayout({ children, params }: Props) {
{
title: t("aiProviderAuthSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
},
{
title: t("aiProviderBudgetSettings"),
href: "/{orgId}/settings/ai-providers/{providerId}/budget"
}
];
+418
View File
@@ -0,0 +1,418 @@
"use client";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
AI_BUDGET_PERIODS,
AI_BUDGET_UNITS,
getAiBudgetScopeBodyField,
type AiBudgetPeriod,
type AiBudgetScope,
type AiBudgetUnit
} from "@app/lib/aiBudgetScope";
import { aiBudgetQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AiBudget } from "@server/db";
import { Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type BudgetRow = {
key: string;
budgetId?: number;
amount: string;
unit: AiBudgetUnit;
period: AiBudgetPeriod;
};
function rowsFromBudgets(budgets: AiBudget[]): BudgetRow[] {
return budgets.map((budget) => ({
key: String(budget.budgetId),
budgetId: budget.budgetId,
amount: String(budget.amount),
unit: budget.unit,
period: budget.period
}));
}
function comboKey(unit: AiBudgetUnit, period: AiBudgetPeriod): string {
return `${unit}:${period}`;
}
function nextAvailableCombo(rows: BudgetRow[]): {
unit: AiBudgetUnit;
period: AiBudgetPeriod;
} {
const used = new Set(rows.map((row) => comboKey(row.unit, row.period)));
for (const unit of AI_BUDGET_UNITS) {
for (const period of AI_BUDGET_PERIODS) {
if (!used.has(comboKey(unit, period))) {
return { unit, period };
}
}
}
return { unit: "usd", period: "monthly" };
}
export function BudgetsEditor({
scope,
orgId,
title,
description
}: {
scope: AiBudgetScope;
orgId: string;
title: string;
description: string;
}) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const t = useTranslations();
const [rows, setRows] = useState<BudgetRow[]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [attemptedSave, setAttemptedSave] = useState(false);
const budgetsQuery = useQuery(aiBudgetQueries.scoped({ scope }));
useEffect(() => {
if (!budgetsQuery.data) return;
setRows(rowsFromBudgets(budgetsQuery.data));
setAttemptedSave(false);
}, [budgetsQuery.data]);
const conflictingKeys = useMemo(() => {
const counts = new Map<string, number>();
for (const row of rows) {
const key = comboKey(row.unit, row.period);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const conflicting = new Set<string>();
for (const row of rows) {
const key = comboKey(row.unit, row.period);
if ((counts.get(key) ?? 0) > 1) {
conflicting.add(row.key);
}
}
return conflicting;
}, [rows]);
const invalidAmountKeys = useMemo(() => {
const invalid = new Set<string>();
for (const row of rows) {
const amount = Number(row.amount);
if (!row.amount.trim() || !Number.isFinite(amount) || amount <= 0) {
invalid.add(row.key);
}
}
return invalid;
}, [rows]);
const hasErrors = conflictingKeys.size > 0 || invalidAmountKeys.size > 0;
function addRow() {
const combo = nextAvailableCombo(rows);
setRows((prev) => [
...prev,
{
key: crypto.randomUUID(),
amount: "",
unit: combo.unit,
period: combo.period
}
]);
}
function removeRow(key: string) {
setRows((prev) => prev.filter((row) => row.key !== key));
}
function updateRow(key: string, patch: Partial<BudgetRow>) {
setRows((prev) =>
prev.map((row) => (row.key === key ? { ...row, ...patch } : row))
);
}
async function onSave() {
setAttemptedSave(true);
if (hasErrors) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: conflictingKeys.size
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")
});
return;
}
setSaveLoading(true);
try {
const existing = budgetsQuery.data ?? [];
const existingById = new Map(
existing.map((budget) => [budget.budgetId, budget])
);
const currentBudgetIds = new Set(
rows
.filter((row) => row.budgetId !== undefined)
.map((row) => row.budgetId as number)
);
const bodyField = getAiBudgetScopeBodyField(scope);
const toDelete = existing.filter(
(budget) => !currentBudgetIds.has(budget.budgetId)
);
const toCreate = rows.filter((row) => row.budgetId === undefined);
const toUpdate = rows.filter((row) => {
if (row.budgetId === undefined) return false;
const existingBudget = existingById.get(row.budgetId);
if (!existingBudget) return false;
return (
existingBudget.amount !== Number(row.amount) ||
existingBudget.unit !== row.unit ||
existingBudget.period !== row.period
);
});
await Promise.all([
...toDelete.map((budget) =>
api.delete(`/ai-budget/${budget.budgetId}`)
),
...toCreate.map((row) =>
api.put(`/org/${orgId}/ai-budget`, {
[bodyField]: scope.id,
amount: Number(row.amount),
unit: row.unit,
period: row.period
})
),
...toUpdate.map((row) =>
api.post(`/ai-budget/${row.budgetId}`, {
amount: Number(row.amount),
unit: row.unit,
period: row.period
})
)
]);
await queryClient.invalidateQueries(
aiBudgetQueries.scoped({ scope })
);
toast({
title: t("success"),
description: t("aiBudgetUpdated")
});
} catch (e) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
} finally {
setSaveLoading(false);
}
}
const periodLabels: Record<AiBudgetPeriod, string> = {
hourly: t("aiBudgetPeriodHourly"),
daily: t("aiBudgetPeriodDaily"),
weekly: t("aiBudgetPeriodWeekly"),
monthly: t("aiBudgetPeriodMonthly"),
yearly: t("aiBudgetPeriodYearly"),
lifetime: t("aiBudgetPeriodLifetime")
};
const unitLabels: Record<AiBudgetUnit, string> = {
usd: t("aiBudgetUnitUsd"),
tokens: t("aiBudgetUnitTokens")
};
return (
<SettingsSection>
<SettingsSectionHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<SettingsSectionTitle>{title}</SettingsSectionTitle>
<SettingsSectionDescription>
{description}
</SettingsSectionDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
disabled={budgetsQuery.isLoading}
className="shrink-0"
>
<Plus className="h-4 w-4 mr-1" />
{t("aiBudgetAdd")}
</Button>
</div>
</SettingsSectionHeader>
<SettingsSectionBody>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("aiBudgetEmpty")}
</p>
) : (
<div className="space-y-3">
{rows.map((row) => {
const showConflict = conflictingKeys.has(row.key);
const showInvalidAmount =
attemptedSave &&
invalidAmountKeys.has(row.key);
return (
<div key={row.key} className="space-y-1">
<div className="flex items-start gap-2">
<Input
type="number"
min="0"
step="any"
placeholder={t(
"aiBudgetAmountPlaceholder"
)}
value={row.amount}
aria-invalid={showInvalidAmount}
disabled={
saveLoading ||
budgetsQuery.isLoading
}
onChange={(e) =>
updateRow(row.key, {
amount: e.target.value
})
}
className="flex-1"
/>
<Select
value={row.period}
onValueChange={(value) =>
updateRow(row.key, {
period: value as AiBudgetPeriod
})
}
disabled={
saveLoading ||
budgetsQuery.isLoading
}
>
<SelectTrigger
className="w-44 shrink-0"
aria-invalid={showConflict}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_BUDGET_PERIODS.map(
(period) => (
<SelectItem
key={period}
value={period}
>
{
periodLabels[
period
]
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Select
value={row.unit}
onValueChange={(value) =>
updateRow(row.key, {
unit: value as AiBudgetUnit
})
}
disabled={
saveLoading ||
budgetsQuery.isLoading
}
>
<SelectTrigger
className="w-32 shrink-0"
aria-invalid={showConflict}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_BUDGET_UNITS.map(
(unit) => (
<SelectItem
key={unit}
value={unit}
>
{unitLabels[unit]}
</SelectItem>
)
)}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
disabled={
saveLoading ||
budgetsQuery.isLoading
}
onClick={() => removeRow(row.key)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
{showConflict && (
<p className="text-xs text-destructive">
{t("aiBudgetConflictError")}
</p>
)}
{showInvalidAmount && (
<p className="text-xs text-destructive">
{t("aiBudgetInvalidAmountError")}
</p>
)}
</div>
);
})}
</div>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="button"
loading={saveLoading}
disabled={saveLoading || budgetsQuery.isLoading}
onClick={onSave}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { AiBudget } from "@server/db";
export type AiBudgetScopeType =
| "provider"
| "model"
| "resource"
| "siteResource"
| "role";
export type AiBudgetScope = {
type: AiBudgetScopeType;
id: number;
};
export type AiBudgetScopeBodyField =
| "providerId"
| "modelId"
| "resourceId"
| "siteResourceId"
| "roleId";
const scopeConfig: Record<
AiBudgetScopeType,
{ listPath: (id: number) => string; bodyField: AiBudgetScopeBodyField }
> = {
provider: {
listPath: (id) => `/ai-provider/${id}/ai-budgets`,
bodyField: "providerId"
},
model: {
listPath: (id) => `/ai-model/${id}/ai-budgets`,
bodyField: "modelId"
},
resource: {
listPath: (id) => `/resource/${id}/ai-budgets`,
bodyField: "resourceId"
},
siteResource: {
listPath: (id) => `/site-resource/${id}/ai-budgets`,
bodyField: "siteResourceId"
},
role: {
listPath: (id) => `/role/${id}/ai-budgets`,
bodyField: "roleId"
}
};
export function getAiBudgetScopeListPath(scope: AiBudgetScope): string {
return scopeConfig[scope.type].listPath(scope.id);
}
export function getAiBudgetScopeBodyField(
scope: AiBudgetScope
): AiBudgetScopeBodyField {
return scopeConfig[scope.type].bodyField;
}
export type AiBudgetUnit = AiBudget["unit"];
export type AiBudgetPeriod = AiBudget["period"];
export const AI_BUDGET_UNITS: AiBudgetUnit[] = ["usd", "tokens"];
export const AI_BUDGET_PERIODS: AiBudgetPeriod[] = [
"hourly",
"daily",
"weekly",
"monthly",
"yearly",
"lifetime"
];
+18
View File
@@ -63,6 +63,11 @@ import type {
ListAiModelsResponse,
ListAiProvidersResponse
} from "@server/routers/aiProvider/types";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response";
import {
@@ -1213,6 +1218,19 @@ export const aiProviderQueries = {
})
};
export const aiBudgetQueries = {
scoped: ({ scope }: { scope: AiBudgetScope }) =>
queryOptions({
queryKey: ["AI_BUDGETS", scope.type, scope.id] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListAiBudgetsByScopeResponse>
>(getAiBudgetScopeListPath(scope), { signal });
return res.data.data.budgets;
}
})
};
export const resourceQueries = {
resourceUsers: ({ resourceId }: { resourceId: number }) =>
queryOptions({