Assign categories to expenses (#28)

* add expense categories

* set category to Payment for reimbursements

* Insert categories as part of the migration

* Display category groups

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
This commit is contained in:
Chris Johnston
2024-01-11 21:38:30 +00:00
committed by GitHub
parent 057f3e9c53
commit 45ee9cdba4
8 changed files with 153 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
import { ExpenseForm } from '@/components/expense-form'
import { deleteExpense, getExpense, getGroup, updateExpense } from '@/lib/api'
import { deleteExpense, getExpense, getCategories, getGroup, updateExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
@@ -13,6 +13,7 @@ export default async function EditExpensePage({
}: {
params: { groupId: string; expenseId: string }
}) {
const categories = await getCategories()
const group = await getGroup(groupId)
if (!group) notFound()
const expense = await getExpense(groupId, expenseId)
@@ -35,6 +36,7 @@ export default async function EditExpensePage({
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction}
/>

View File

@@ -1,5 +1,5 @@
import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getGroup } from '@/lib/api'
import { createExpense, getGroup, getCategories } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
@@ -13,6 +13,7 @@ export default async function ExpensePage({
}: {
params: { groupId: string }
}) {
const categories = await getCategories()
const group = await getGroup(groupId)
if (!group) notFound()
@@ -23,5 +24,5 @@ export default async function ExpensePage({
redirect(`/groups/${groupId}`)
}
return <ExpenseForm group={group} onSubmit={createExpenseAction} />
return <ExpenseForm group={group} categories={categories} onSubmit={createExpenseAction} />
}

View File

@@ -28,14 +28,17 @@ import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getExpense, getGroup } from '@/lib/api'
import { getCategories, getExpense, getGroup } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { Category } from '@prisma/client'
import { Save, Trash2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
@@ -44,11 +47,18 @@ import { match } from 'ts-pattern'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues) => Promise<void>
onDelete?: () => Promise<void>
}
export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
export function ExpenseForm({
group,
expense,
categories,
onSubmit,
onDelete,
}: Props) {
const isCreate = expense === undefined
const searchParams = useSearchParams()
const getSelectedPayer = (field?: { value: string }) => {
@@ -67,6 +77,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
title: expense.title,
expenseDate: expense.expenseDate ?? new Date(),
amount: String(expense.amount / 100) as unknown as number, // hack
category: expense.categoryId,
paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId,
@@ -82,6 +93,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
amount: String(
(Number(searchParams.get('amount')) || 0) / 100,
) as unknown as number, // hack
category: 1, // category with Id 1 is Payment
paidBy: searchParams.get('from') ?? undefined,
paidFor: [
searchParams.get('to')
@@ -95,6 +107,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
title: '',
expenseDate: new Date(),
amount: 0,
category: 0, // category with Id 0 is General
paidFor: [],
paidBy: getSelectedPayer(),
isReimbursement: false,
@@ -102,6 +115,14 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
},
})
const categoriesByGroup = categories.reduce<Record<string, Category[]>>(
(acc, category) => ({
...acc,
[category.grouping]: [...(acc[category.grouping] ?? []), category],
}),
{},
)
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
@@ -138,7 +159,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
name="expenseDate"
render={({ field }) => (
<FormItem className="sm:order-1">
<FormLabel>Expense Date</FormLabel>
<FormLabel>Expense date</FormLabel>
<FormControl>
<Input
className="date-base"
@@ -201,6 +222,43 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Category</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value.toString()}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{Object.keys(categoriesByGroup).map((group) => (
<SelectGroup key={group}>
<SelectLabel className="-ml-6">{group}</SelectLabel>
{categoriesByGroup[group].map(({ id, name }) => (
<SelectItem
key={id.toString()}
value={id.toString()}
>
{name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<FormDescription>
Select the expense category.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidBy"

View File

@@ -48,6 +48,7 @@ export async function createExpense(
id: randomId(),
groupId,
expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category,
amount: expenseFormValues.amount,
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
@@ -124,6 +125,7 @@ export async function updateExpense(
expenseDate: expenseFormValues.expenseDate,
amount: expenseFormValues.amount,
title: expenseFormValues.title,
categoryId: expenseFormValues.category,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
paidFor: {
@@ -207,11 +209,16 @@ export async function getGroup(groupId: string) {
})
}
export async function getCategories() {
const prisma = await getPrisma()
return prisma.category.findMany()
}
export async function getGroupExpenses(groupId: string) {
const prisma = await getPrisma()
return prisma.expense.findMany({
where: { groupId },
include: { paidFor: { include: { participant: true } }, paidBy: true },
include: { paidFor: { include: { participant: true } }, paidBy: true, category: true },
orderBy: { expenseDate: 'desc' },
})
}
@@ -220,6 +227,6 @@ export async function getExpense(groupId: string, expenseId: string) {
const prisma = await getPrisma()
return prisma.expense.findUnique({
where: { id: expenseId },
include: { paidBy: true, paidFor: true },
include: { paidBy: true, paidFor: true, category: true },
})
}

View File

@@ -45,6 +45,7 @@ export const expenseFormSchema = z
title: z
.string({ required_error: 'Please enter a title.' })
.min(2, 'Enter at least two characters.'),
category: z.coerce.number().default(0),
amount: z
.union(
[

View File

@@ -80,6 +80,7 @@ async function main() {
amount: Math.round(expenseRow.amount * 100),
groupId: groupRow.id,
title: expenseRow.description,
categoryId: 1,
expenseDate: new Date(expenseRow.created_at.toDateString()),
createdAt: expenseRow.created_at,
isReimbursement: expenseRow.is_reimbursement === true,