Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): add budget spend management #1224

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Check, Minus, X } from "lucide-react";
import type { BudgetsSectionProps } from "./budgets";
import { DeleteBudgetButton } from "./delete-budget-button";
import { EditBudgetButton } from "./edit-budget-button";

export const BudgetsTable: React.FC<BudgetsSectionProps> = ({ budgets, currentBilling }) => {
return (
<Table>
{budgets.length === 0 ? <TableCaption>No budgets found</TableCaption> : null}
<TableHeader>
<TableRow>
<TableHead>Enabled</TableHead>
<TableHead>Name</TableHead>
<TableHead>Budget</TableHead>
<TableHead>Amount used</TableHead>
<TableHead>Current vs. budgeted</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{budgets.map((budget, i) => {
const usage = Math.floor((currentBilling / budget.fixedAmount) * 10000) / 100;

return (
// biome-ignore lint/suspicious/noArrayIndexKey: I got nothing better right now
<TableRow key={i}>
<TableCell>{budget.enabled ? <Check /> : <X />}</TableCell>
<TableCell className="w-fit">
{budget.name ? (
<Badge variant="secondary">{budget.name}</Badge>
) : (
<Minus className="w-4 h-4 text-gray-300" />
)}
</TableCell>
<TableCell>${budget.fixedAmount}</TableCell>
<TableCell>${currentBilling}</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-content-subtle text-xs max-w-40">
<Progress value={usage} className="h-2" />
{`${usage}%`}
</div>
</TableCell>
<TableCell className="flex items-center gap-2">
<EditBudgetButton budget={budget} />
<DeleteBudgetButton budgetId={budget.id} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { Budget } from "@unkey/db";
import { BudgetsTable } from "./budgets-table";
import { CreateBudgetButton } from "./create-budget-button";

export type BudgetsSectionProps = {
currentBilling: number;
budgets: Budget[];
};

export function BudgetsSection({ currentBilling, budgets }: BudgetsSectionProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex justify-between items-center">
Budgets
<CreateBudgetButton />
</CardTitle>
<CardDescription>
Set custom budgets that alert you when your costs and usage exceed your budgeted amount.
</CardDescription>
</CardHeader>

<CardContent>
<BudgetsTable currentBilling={currentBilling} budgets={budgets} />
</CardContent>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"use client";

import { Loading } from "@/components/dashboard/loading";
import { Button } from "@/components/ui/button";

import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/toaster";
import { trpc } from "@/lib/trpc/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

export const createBudgetFormSchema = z.object({
name: z.string().optional(),
fixedAmount: z.coerce
.number({
errorMap: (issue, { defaultError }) => ({
message:
issue.code === "invalid_type" ? "Budget amount must be greater than 0" : defaultError,
}),
})
.positive({ message: "Budget amount must be greater than 0" }),
additionalEmails: z
.string()
.optional()
.refine(
(val) =>
!val ||
z.array(z.string().email("Invalid email format")).max(10).safeParse(val.split(",")).success,
"Invalid email list. Ensure emails are correctly formatted and do not exceed 10.",
),
});

export const CreateBudgetButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = (
props,
) => {
const router = useRouter();

const [isOpen, setIsOpen] = useState(false);

const form = useForm<z.infer<typeof createBudgetFormSchema>>({
resolver: zodResolver(createBudgetFormSchema),
defaultValues: {
additionalEmails: "",
},
});

const createBudget = trpc.budget.create.useMutation({
onSuccess() {
toast.success("Your Budget has been created");

router.refresh();

setIsOpen(false);
},
onError(err) {
console.error(err);
toast.error(err.message);
},
});

async function onSubmit(values: z.infer<typeof createBudgetFormSchema>) {
await createBudget.mutateAsync({
...values,
additionalEmails: values.additionalEmails?.split(",").filter(Boolean) || undefined,
});
}

function onOpenChange(open: boolean) {
if (open) {
form.reset({
fixedAmount: undefined,
additionalEmails: "",
});
}
setIsOpen(open);
}

return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button className="flex-row items-center gap-1 font-semibold " {...props}>
Create Budget
</Button>
</DialogTrigger>

<DialogContent className="w-11/12 max-sm: ">
<DialogHeader>
<DialogTitle>Create a new Budget</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Budget Name</FormLabel>
<FormControl>
<Input {...field} autoComplete="off" />
</FormControl>
<FormDescription>Provide a descriptive name for this budget.</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="fixedAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Budget amount ($)</FormLabel>
<FormControl>
<Input
type="number"
min={0}
className="max-w-[120px]"
{...field}
autoComplete="off"
placeholder="0.00"
step="0.01"
/>
</FormControl>
<FormDescription>Enter your budgeted amount.</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="additionalEmails"
render={({ field }) => (
<FormItem>
<FormLabel>Email recipients</FormLabel>
<FormControl>
<Textarea
rows={3}
{...field}
autoComplete="off"
placeholder="Separate email addresses using commas"
/>
</FormControl>
<FormDescription>
Specify up to 10 additional email recipients you want to notify when the
threshold has exceeded. The account owner will also get notified.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<DialogFooter className="flex-row justify-end gap-2 pt-4 ">
<Button
disabled={createBudget.isLoading || form.formState.isSubmitting}
className="mt-4 "
type="submit"
>
{createBudget.isLoading ? <Loading /> : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";
import { Button } from "@/components/ui/button";
import React, { useState } from "react";

import { toast } from "@/components/ui/toaster";

import { Loading } from "@/components/dashboard/loading";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { trpc } from "@/lib/trpc/client";
import { Trash } from "lucide-react";
import { useRouter } from "next/navigation";

type Props = {
budgetId: string;
};

export const DeleteBudgetButton: React.FC<Props> = ({ budgetId }) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);

const deleteBudget = trpc.budget.delete.useMutation({
onSuccess() {
toast.success("Budget deleted");

router.refresh();

setIsOpen(false);
},
onError(error) {
console.error(error);
toast.error(error.message);
},
});

return (
<>
<Button size="icon" type="button" onClick={() => setIsOpen(true)} variant="alert">
<Trash className="w-4 h-4" />
</Button>

<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="border-[#b80f07]">
<DialogHeader>
<DialogTitle>Delete Budget</DialogTitle>
<DialogDescription>
This budget will be deleted. This action cannot be undone.
</DialogDescription>
</DialogHeader>

<DialogFooter className="justify-end">
<Button type="button" onClick={() => setIsOpen(false)} variant="secondary">
Cancel
</Button>
<Button
type="submit"
variant="alert"
disabled={deleteBudget.isLoading}
onClick={() => deleteBudget.mutate({ budgetId })}
>
{deleteBudget.isLoading ? <Loading /> : "Delete Budget"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
Loading
Loading