mirror of
https://github.com/spliit-app/spliit.git
synced 2025-12-06 09:29:39 +01:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user