374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import FormFooter from "@/components/formFooter";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import NumberAndSelect from "@/components/ui/numberAndSelect";
|
|
import { resetDurationOptions } from "@/lib/constants/governance";
|
|
import { getErrorMessage, useCreateCustomerMutation, useUpdateCustomerMutation } from "@/lib/store";
|
|
import { CreateCustomerRequest, Customer, UpdateCustomerRequest } from "@/lib/types/governance";
|
|
import { formatCurrency } from "@/lib/utils/governance";
|
|
import { Validator } from "@/lib/utils/validation";
|
|
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import isEqual from "lodash.isequal";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
interface CustomerDialogProps {
|
|
customer?: Customer | null;
|
|
onSave: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
interface CustomerFormData {
|
|
name: string;
|
|
// Budget
|
|
budgetMaxLimit: number | undefined;
|
|
budgetResetDuration: string;
|
|
// Rate Limit
|
|
tokenMaxLimit: number | undefined;
|
|
tokenResetDuration: string;
|
|
requestMaxLimit: number | undefined;
|
|
requestResetDuration: string;
|
|
isDirty: boolean;
|
|
}
|
|
|
|
// Helper function to create initial state
|
|
const createInitialState = (customer?: Customer | null): Omit<CustomerFormData, "isDirty"> => {
|
|
return {
|
|
name: customer?.name || "",
|
|
// Budget
|
|
budgetMaxLimit: customer?.budget?.max_limit ?? undefined,
|
|
budgetResetDuration: customer?.budget?.reset_duration || "1M",
|
|
// Rate Limit
|
|
tokenMaxLimit: customer?.rate_limit?.token_max_limit ?? undefined,
|
|
tokenResetDuration: customer?.rate_limit?.token_reset_duration || "1h",
|
|
requestMaxLimit: customer?.rate_limit?.request_max_limit ?? undefined,
|
|
requestResetDuration: customer?.rate_limit?.request_reset_duration || "1h",
|
|
};
|
|
};
|
|
|
|
export default function CustomerDialog({ customer, onSave, onCancel }: CustomerDialogProps) {
|
|
const isEditing = !!customer;
|
|
const [initialState] = useState<Omit<CustomerFormData, "isDirty">>(createInitialState(customer));
|
|
const [formData, setFormData] = useState<CustomerFormData>({
|
|
...initialState,
|
|
isDirty: false,
|
|
});
|
|
|
|
const hasCreateAccess = useRbac(RbacResource.Customers, RbacOperation.Create);
|
|
const hasUpdateAccess = useRbac(RbacResource.Customers, RbacOperation.Update);
|
|
const hasPermission = isEditing ? hasUpdateAccess : hasCreateAccess;
|
|
|
|
// RTK Query hooks
|
|
const [createCustomer, { isLoading: isCreating }] = useCreateCustomerMutation();
|
|
const [updateCustomer, { isLoading: isUpdating }] = useUpdateCustomerMutation();
|
|
const loading = isCreating || isUpdating;
|
|
|
|
// Track isDirty state
|
|
useEffect(() => {
|
|
const currentData = {
|
|
name: formData.name,
|
|
budgetMaxLimit: formData.budgetMaxLimit,
|
|
budgetResetDuration: formData.budgetResetDuration,
|
|
tokenMaxLimit: formData.tokenMaxLimit,
|
|
tokenResetDuration: formData.tokenResetDuration,
|
|
requestMaxLimit: formData.requestMaxLimit,
|
|
requestResetDuration: formData.requestResetDuration,
|
|
};
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
isDirty: !isEqual(initialState, currentData),
|
|
}));
|
|
}, [
|
|
formData.name,
|
|
formData.budgetMaxLimit,
|
|
formData.budgetResetDuration,
|
|
formData.tokenMaxLimit,
|
|
formData.tokenResetDuration,
|
|
formData.requestMaxLimit,
|
|
formData.requestResetDuration,
|
|
initialState,
|
|
]);
|
|
|
|
// Values for validation and submission (already numbers)
|
|
const budgetMaxLimitNum = formData.budgetMaxLimit;
|
|
const tokenMaxLimitNum = formData.tokenMaxLimit;
|
|
const requestMaxLimitNum = formData.requestMaxLimit;
|
|
|
|
// Validation
|
|
const validator = useMemo(
|
|
() =>
|
|
new Validator([
|
|
// Basic validation
|
|
Validator.required(formData.name.trim(), "Customer name is required"),
|
|
|
|
// Check if anything is dirty
|
|
Validator.custom(formData.isDirty, "No changes to save"),
|
|
|
|
// Budget validation
|
|
...(formData.budgetMaxLimit !== undefined && formData.budgetMaxLimit !== null
|
|
? [
|
|
Validator.minValue(budgetMaxLimitNum ?? 0, 0.01, "Budget max limit must be greater than $0.01"),
|
|
Validator.required(formData.budgetResetDuration, "Budget reset duration is required"),
|
|
]
|
|
: []),
|
|
|
|
// Rate limit validation - token limits
|
|
...(formData.tokenMaxLimit !== undefined && formData.tokenMaxLimit !== null
|
|
? [
|
|
Validator.minValue(tokenMaxLimitNum ?? 0, 1, "Token max limit must be at least 1"),
|
|
Validator.required(formData.tokenResetDuration, "Token reset duration is required"),
|
|
]
|
|
: []),
|
|
|
|
// Rate limit validation - request limits
|
|
...(formData.requestMaxLimit !== undefined && formData.requestMaxLimit !== null
|
|
? [
|
|
Validator.minValue(requestMaxLimitNum ?? 0, 1, "Request max limit must be at least 1"),
|
|
Validator.required(formData.requestResetDuration, "Request reset duration is required"),
|
|
]
|
|
: []),
|
|
]),
|
|
[formData, budgetMaxLimitNum, tokenMaxLimitNum, requestMaxLimitNum],
|
|
);
|
|
|
|
const updateField = <K extends keyof CustomerFormData>(field: K, value: CustomerFormData[K]) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!validator.isValid()) {
|
|
toast.error(validator.getFirstError());
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (isEditing && customer) {
|
|
// Update existing customer
|
|
const updateData: UpdateCustomerRequest = {
|
|
name: formData.name,
|
|
};
|
|
|
|
// Detect budget changes using had/has pattern
|
|
const hadBudget = !!customer.budget;
|
|
const hasBudget = budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null;
|
|
if (hasBudget) {
|
|
updateData.budget = {
|
|
max_limit: budgetMaxLimitNum,
|
|
reset_duration: formData.budgetResetDuration,
|
|
};
|
|
} else if (hadBudget) {
|
|
updateData.budget = {} as UpdateCustomerRequest["budget"];
|
|
}
|
|
|
|
// Detect rate limit changes using had/has pattern
|
|
const hadRateLimit = !!customer.rate_limit;
|
|
const hasRateLimit =
|
|
(tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
|
|
(requestMaxLimitNum !== undefined && requestMaxLimitNum !== null);
|
|
if (hasRateLimit) {
|
|
updateData.rate_limit = {
|
|
token_max_limit: tokenMaxLimitNum,
|
|
token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined,
|
|
request_max_limit: requestMaxLimitNum,
|
|
request_reset_duration:
|
|
requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined,
|
|
};
|
|
} else if (hadRateLimit) {
|
|
updateData.rate_limit = {} as UpdateCustomerRequest["rate_limit"];
|
|
}
|
|
|
|
await updateCustomer({ customerId: customer.id, data: updateData }).unwrap();
|
|
toast.success("Customer updated successfully");
|
|
} else {
|
|
// Create new customer
|
|
const createData: CreateCustomerRequest = {
|
|
name: formData.name,
|
|
};
|
|
|
|
// Add budget if enabled
|
|
if (budgetMaxLimitNum !== undefined && budgetMaxLimitNum !== null) {
|
|
createData.budget = {
|
|
max_limit: budgetMaxLimitNum,
|
|
reset_duration: formData.budgetResetDuration,
|
|
};
|
|
}
|
|
|
|
// Add rate limit if enabled (token or request limits)
|
|
if (
|
|
(tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null) ||
|
|
(requestMaxLimitNum !== undefined && requestMaxLimitNum !== null)
|
|
) {
|
|
createData.rate_limit = {
|
|
token_max_limit: tokenMaxLimitNum,
|
|
token_reset_duration: tokenMaxLimitNum !== undefined && tokenMaxLimitNum !== null ? formData.tokenResetDuration : undefined,
|
|
request_max_limit: requestMaxLimitNum,
|
|
request_reset_duration:
|
|
requestMaxLimitNum !== undefined && requestMaxLimitNum !== null ? formData.requestResetDuration : undefined,
|
|
};
|
|
}
|
|
|
|
await createCustomer(createData).unwrap();
|
|
toast.success("Customer created successfully");
|
|
}
|
|
|
|
onSave();
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={onCancel}>
|
|
<DialogContent className="max-w-2xl" data-testid="customer-dialog-content">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">{isEditing ? "Edit Customer" : "Create Customer"}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEditing
|
|
? "Update the customer information and settings."
|
|
: "Create a new customer account to organize teams and manage resources."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="space-y-6">
|
|
{/* Basic Information */}
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Customer Name *</Label>
|
|
<Input
|
|
id="name"
|
|
data-testid="customer-name-input"
|
|
placeholder="e.g., Acme Corporation"
|
|
value={formData.name}
|
|
maxLength={50}
|
|
onChange={(e) => updateField("name", e.target.value)}
|
|
/>
|
|
<p className="text-muted-foreground text-sm">This name will be used to identify the customer account.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Budget Configuration */}
|
|
<NumberAndSelect
|
|
id="budgetMaxLimit"
|
|
label="Maximum Spend (USD)"
|
|
value={formData.budgetMaxLimit}
|
|
selectValue={formData.budgetResetDuration}
|
|
onChangeNumber={(value) => updateField("budgetMaxLimit", value)}
|
|
onChangeSelect={(value) => updateField("budgetResetDuration", value)}
|
|
options={resetDurationOptions}
|
|
dataTestId="budget-max-limit-input"
|
|
/>
|
|
|
|
{/* Rate Limit Configuration - Token Limits */}
|
|
<NumberAndSelect
|
|
id="tokenMaxLimit"
|
|
label="Maximum Tokens"
|
|
value={formData.tokenMaxLimit}
|
|
selectValue={formData.tokenResetDuration}
|
|
onChangeNumber={(value) => updateField("tokenMaxLimit", value)}
|
|
onChangeSelect={(value) => updateField("tokenResetDuration", value)}
|
|
options={resetDurationOptions}
|
|
/>
|
|
|
|
{/* Rate Limit Configuration - Request Limits */}
|
|
<NumberAndSelect
|
|
id="requestMaxLimit"
|
|
label="Maximum Requests"
|
|
value={formData.requestMaxLimit}
|
|
selectValue={formData.requestResetDuration}
|
|
onChangeNumber={(value) => updateField("requestMaxLimit", value)}
|
|
onChangeSelect={(value) => updateField("requestResetDuration", value)}
|
|
options={resetDurationOptions}
|
|
/>
|
|
|
|
{/* Current Usage Section (only shown when editing with existing limits) */}
|
|
{isEditing && (customer?.budget || customer?.rate_limit) && (
|
|
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
|
|
<p className="text-sm font-medium">Current Usage</p>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
{customer?.budget && (
|
|
<div className="space-y-1">
|
|
<p className="text-muted-foreground text-xs">Budget</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm">
|
|
{formatCurrency(customer.budget.current_usage)} / {formatCurrency(customer.budget.max_limit)}
|
|
</span>
|
|
<Badge
|
|
variant={customer.budget.current_usage >= customer.budget.max_limit ? "destructive" : "default"}
|
|
className="text-xs"
|
|
>
|
|
{Math.round((customer.budget.current_usage / customer.budget.max_limit) * 100)}%
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
Last Reset: {formatDistanceToNow(new Date(customer.budget.last_reset), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{customer?.rate_limit?.token_max_limit && (
|
|
<div className="space-y-1">
|
|
<p className="text-muted-foreground text-xs">Tokens</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm">
|
|
{customer.rate_limit.token_current_usage.toLocaleString()} /{" "}
|
|
{customer.rate_limit.token_max_limit.toLocaleString()}
|
|
</span>
|
|
<Badge
|
|
variant={
|
|
customer.rate_limit.token_current_usage >= customer.rate_limit.token_max_limit ? "destructive" : "default"
|
|
}
|
|
className="text-xs"
|
|
>
|
|
{Math.round((customer.rate_limit.token_current_usage / customer.rate_limit.token_max_limit) * 100)}%
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.token_last_reset), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{customer?.rate_limit?.request_max_limit && (
|
|
<div className="space-y-1">
|
|
<p className="text-muted-foreground text-xs">Requests</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm">
|
|
{customer.rate_limit.request_current_usage.toLocaleString()} /{" "}
|
|
{customer.rate_limit.request_max_limit.toLocaleString()}
|
|
</span>
|
|
<Badge
|
|
variant={
|
|
customer.rate_limit.request_current_usage >= customer.rate_limit.request_max_limit ? "destructive" : "default"
|
|
}
|
|
className="text-xs"
|
|
>
|
|
{Math.round((customer.rate_limit.request_current_usage / customer.rate_limit.request_max_limit) * 100)}%
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
Last Reset: {formatDistanceToNow(new Date(customer.rate_limit.request_last_reset), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<FormFooter
|
|
validator={validator}
|
|
label="Customer"
|
|
onCancel={onCancel}
|
|
isLoading={loading}
|
|
isEditing={isEditing}
|
|
hasPermission={hasPermission}
|
|
/>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |