make the budget match the rules

This commit is contained in:
Owen
2026-08-10 12:20:47 -04:00
parent 211d3a53f5
commit 7e6e0a8a9c
2 changed files with 242 additions and 148 deletions
+3
View File
@@ -1657,6 +1657,9 @@
"aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits", "aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits",
"aiBudgetAdd": "Add Budget", "aiBudgetAdd": "Add Budget",
"aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.", "aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.",
"aiBudgetUnit": "Spend Type",
"aiBudgetPeriod": "Reset Period",
"aiBudgetAmount": "Maximum Spend",
"aiBudgetAmountPlaceholder": "Maximum spend", "aiBudgetAmountPlaceholder": "Maximum spend",
"aiBudgetPeriodHourly": "Hourly", "aiBudgetPeriodHourly": "Hourly",
"aiBudgetPeriodDaily": "Daily", "aiBudgetPeriodDaily": "Daily",
+239 -148
View File
@@ -1,14 +1,18 @@
"use client"; "use client";
import { import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionFooter, SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { import {
Select, Select,
@@ -17,6 +21,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
@@ -247,160 +259,239 @@ export function BudgetsEditor({
tokens: t("aiBudgetUnitTokens") tokens: t("aiBudgetUnitTokens")
}; };
const addRowButton = (
<Button
type="button"
variant="outline"
onClick={addRow}
disabled={saveLoading || budgetsQuery.isLoading}
>
<Plus className="h-4 w-4 mr-2" />
{t("aiBudgetAdd")}
</Button>
);
return ( return (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<div className="flex items-start justify-between gap-4"> <SettingsSectionTitle>{title}</SettingsSectionTitle>
<div className="space-y-0.5"> <SettingsSectionDescription>
<SettingsSectionTitle>{title}</SettingsSectionTitle> {description}
<SettingsSectionDescription> </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> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{rows.length === 0 ? ( <SettingsSectionForm>
<p className="text-sm text-muted-foreground"> <SettingsFormGrid>
{t("aiBudgetEmpty")} <SettingsFormCell span="half">
</p> <div className="space-y-4">
) : ( <Table>
<div className="space-y-3"> <TableHeader>
{rows.map((row) => { <TableRow>
const showConflict = conflictingKeys.has(row.key); <TableHead>
const showInvalidAmount = {t("aiBudgetUnit")}
attemptedSave && </TableHead>
invalidAmountKeys.has(row.key); <TableHead>
return ( {t("aiBudgetPeriod")}
<div key={row.key} className="space-y-1"> </TableHead>
<div className="flex items-start gap-2"> <TableHead>
<Input {t("aiBudgetAmount")}
type="number" </TableHead>
min="0" <TableHead></TableHead>
step="any" </TableRow>
placeholder={t( </TableHeader>
"aiBudgetAmountPlaceholder" <TableBody>
)} {rows.length === 0 ? (
value={row.amount} <DataTableEmptyState
aria-invalid={showInvalidAmount} colSpan={4}
disabled={ message={t("aiBudgetEmpty")}
saveLoading || action={addRowButton}
budgetsQuery.isLoading compact
} />
onChange={(e) => ) : (
updateRow(row.key, { rows.map((row) => {
amount: e.target.value const showConflict =
}) conflictingKeys.has(
} row.key
className="flex-1" );
/> const showInvalidAmount =
<Select attemptedSave &&
value={row.period} invalidAmountKeys.has(
onValueChange={(value) => row.key
updateRow(row.key, { );
period: value as AiBudgetPeriod return (
}) <TableRow key={row.key}>
} <TableCell>
disabled={ <Select
saveLoading || value={row.unit}
budgetsQuery.isLoading onValueChange={(
} value
> ) =>
<SelectTrigger updateRow(
className="w-44 shrink-0" row.key,
aria-invalid={showConflict} {
> unit: value as AiBudgetUnit
<SelectValue /> }
</SelectTrigger> )
<SelectContent> }
{AI_BUDGET_PERIODS.map( disabled={
(period) => ( saveLoading ||
<SelectItem budgetsQuery.isLoading
key={period} }
value={period} >
> <SelectTrigger
{ className="w-full min-w-0"
periodLabels[ aria-invalid={
period showConflict
] }
} >
</SelectItem> <SelectValue />
) </SelectTrigger>
)} <SelectContent>
</SelectContent> {AI_BUDGET_UNITS.map(
</Select> (
<Select unit
value={row.unit} ) => (
onValueChange={(value) => <SelectItem
updateRow(row.key, { key={
unit: value as AiBudgetUnit unit
}) }
} value={
disabled={ unit
saveLoading || }
budgetsQuery.isLoading >
} {
> unitLabels[
<SelectTrigger unit
className="w-32 shrink-0" ]
aria-invalid={showConflict} }
> </SelectItem>
<SelectValue /> )
</SelectTrigger> )}
<SelectContent> </SelectContent>
{AI_BUDGET_UNITS.map( </Select>
(unit) => ( </TableCell>
<SelectItem <TableCell>
key={unit} <Select
value={unit} value={
> row.period
{unitLabels[unit]} }
</SelectItem> onValueChange={(
) value
)} ) =>
</SelectContent> updateRow(
</Select> row.key,
<Button {
type="button" period: value as AiBudgetPeriod
variant="ghost" }
size="icon" )
className="shrink-0" }
disabled={ disabled={
saveLoading || saveLoading ||
budgetsQuery.isLoading budgetsQuery.isLoading
} }
onClick={() => removeRow(row.key)} >
> <SelectTrigger
<Trash2 className="h-4 w-4 text-destructive" /> className="w-full min-w-0"
</Button> aria-invalid={
</div> showConflict
{showConflict && ( }
<p className="text-xs text-destructive"> >
{t("aiBudgetConflictError")} <SelectValue />
</p> </SelectTrigger>
)} <SelectContent>
{showInvalidAmount && ( {AI_BUDGET_PERIODS.map(
<p className="text-xs text-destructive"> (
{t("aiBudgetInvalidAmountError")} period
</p> ) => (
)} <SelectItem
</div> key={
); period
})} }
</div> value={
)} period
}
>
{
periodLabels[
period
]
}
</SelectItem>
)
)}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<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="w-full min-w-0"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
disabled={
saveLoading ||
budgetsQuery.isLoading
}
onClick={() =>
removeRow(
row.key
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
{(conflictingKeys.size > 0 ||
(attemptedSave &&
invalidAmountKeys.size > 0)) && (
<p className="text-xs text-destructive">
{conflictingKeys.size > 0
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")}
</p>
)}
{rows.length > 0 && addRowButton}
</div>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>