mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-28 02:16:12 +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:
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `categoryId` to the `Expense` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ADD COLUMN "categoryId" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"grouping" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert categories
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (0, 'Uncategorized', 'General');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (1, 'Uncategorized', 'Payment');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (2, 'Entertainment', 'Entertainment');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (3, 'Entertainment', 'Games');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (4, 'Entertainment', 'Movies');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (5, 'Entertainment', 'Music');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (6, 'Entertainment', 'Sports');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (7, 'Food and Drink', 'Food and Drink');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (8, 'Food and Drink', 'Dining Out');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (9, 'Food and Drink', 'Groceries');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (10, 'Food and Drink', 'Liquor');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (11, 'Home', 'Home');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (12, 'Home', 'Electronics');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (13, 'Home', 'Furniture');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (14, 'Home', 'Household Supplies');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (15, 'Home', 'Maintenance');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (16, 'Home', 'Mortgage');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (17, 'Home', 'Pets');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (18, 'Home', 'Rent');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (19, 'Home', 'Services');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (20, 'Life', 'Childcare');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (21, 'Life', 'Clothing');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (22, 'Life', 'Education');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (23, 'Life', 'Gifts');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (24, 'Life', 'Insurance');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (25, 'Life', 'Medical Expenses');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (26, 'Life', 'Taxes');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (27, 'Transportation', 'Transportation');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (28, 'Transportation', 'Bicycle');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (29, 'Transportation', 'Bus/Train');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (30, 'Transportation', 'Car');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (31, 'Transportation', 'Gas/Fuel');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (32, 'Transportation', 'Hotel');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (33, 'Transportation', 'Parking');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (34, 'Transportation', 'Plane');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (35, 'Transportation', 'Taxi');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (36, 'Utilities', 'Utilities');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (37, 'Utilities', 'Cleaning');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (38, 'Utilities', 'Electricity');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (39, 'Utilities', 'Heat/Gas');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (40, 'Utilities', 'Trash');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (41, 'Utilities', 'TV/Phone/Internet');
|
||||||
|
INSERT INTO "Category" ("id", "grouping", "name") VALUES (42, 'Utilities', 'Water');
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -29,11 +29,20 @@ model Participant {
|
|||||||
expensesPaidFor ExpensePaidFor[]
|
expensesPaidFor ExpensePaidFor[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
grouping String
|
||||||
|
name String
|
||||||
|
Expense Expense[]
|
||||||
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
id String @id
|
id String @id
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
expenseDate DateTime @db.Date @default(dbgenerated("CURRENT_DATE"))
|
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||||
title String
|
title String
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
categoryId Int @default(0)
|
||||||
amount Int
|
amount Int
|
||||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||||
paidById String
|
paidById String
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ExpenseForm } from '@/components/expense-form'
|
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 { expenseFormSchema } from '@/lib/schemas'
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
@@ -13,6 +13,7 @@ export default async function EditExpensePage({
|
|||||||
}: {
|
}: {
|
||||||
params: { groupId: string; expenseId: string }
|
params: { groupId: string; expenseId: string }
|
||||||
}) {
|
}) {
|
||||||
|
const categories = await getCategories()
|
||||||
const group = await getGroup(groupId)
|
const group = await getGroup(groupId)
|
||||||
if (!group) notFound()
|
if (!group) notFound()
|
||||||
const expense = await getExpense(groupId, expenseId)
|
const expense = await getExpense(groupId, expenseId)
|
||||||
@@ -35,6 +36,7 @@ export default async function EditExpensePage({
|
|||||||
<ExpenseForm
|
<ExpenseForm
|
||||||
group={group}
|
group={group}
|
||||||
expense={expense}
|
expense={expense}
|
||||||
|
categories={categories}
|
||||||
onSubmit={updateExpenseAction}
|
onSubmit={updateExpenseAction}
|
||||||
onDelete={deleteExpenseAction}
|
onDelete={deleteExpenseAction}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ExpenseForm } from '@/components/expense-form'
|
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 { expenseFormSchema } from '@/lib/schemas'
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
@@ -13,6 +13,7 @@ export default async function ExpensePage({
|
|||||||
}: {
|
}: {
|
||||||
params: { groupId: string }
|
params: { groupId: string }
|
||||||
}) {
|
}) {
|
||||||
|
const categories = await getCategories()
|
||||||
const group = await getGroup(groupId)
|
const group = await getGroup(groupId)
|
||||||
if (!group) notFound()
|
if (!group) notFound()
|
||||||
|
|
||||||
@@ -23,5 +24,5 @@ export default async function ExpensePage({
|
|||||||
redirect(`/groups/${groupId}`)
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { getExpense, getGroup } from '@/lib/api'
|
import { getCategories, getExpense, getGroup } from '@/lib/api'
|
||||||
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
|
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { Category } from '@prisma/client'
|
||||||
import { Save, Trash2 } from 'lucide-react'
|
import { Save, Trash2 } from 'lucide-react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
@@ -44,11 +47,18 @@ import { match } from 'ts-pattern'
|
|||||||
export type Props = {
|
export type Props = {
|
||||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||||
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
|
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
|
||||||
|
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
|
||||||
onSubmit: (values: ExpenseFormValues) => Promise<void>
|
onSubmit: (values: ExpenseFormValues) => Promise<void>
|
||||||
onDelete?: () => 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 isCreate = expense === undefined
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const getSelectedPayer = (field?: { value: string }) => {
|
const getSelectedPayer = (field?: { value: string }) => {
|
||||||
@@ -67,6 +77,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
|||||||
title: expense.title,
|
title: expense.title,
|
||||||
expenseDate: expense.expenseDate ?? new Date(),
|
expenseDate: expense.expenseDate ?? new Date(),
|
||||||
amount: String(expense.amount / 100) as unknown as number, // hack
|
amount: String(expense.amount / 100) as unknown as number, // hack
|
||||||
|
category: expense.categoryId,
|
||||||
paidBy: expense.paidById,
|
paidBy: expense.paidById,
|
||||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||||
participant: participantId,
|
participant: participantId,
|
||||||
@@ -82,6 +93,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
|||||||
amount: String(
|
amount: String(
|
||||||
(Number(searchParams.get('amount')) || 0) / 100,
|
(Number(searchParams.get('amount')) || 0) / 100,
|
||||||
) as unknown as number, // hack
|
) as unknown as number, // hack
|
||||||
|
category: 1, // category with Id 1 is Payment
|
||||||
paidBy: searchParams.get('from') ?? undefined,
|
paidBy: searchParams.get('from') ?? undefined,
|
||||||
paidFor: [
|
paidFor: [
|
||||||
searchParams.get('to')
|
searchParams.get('to')
|
||||||
@@ -95,6 +107,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
|||||||
title: '',
|
title: '',
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
category: 0, // category with Id 0 is General
|
||||||
paidFor: [],
|
paidFor: [],
|
||||||
paidBy: getSelectedPayer(),
|
paidBy: getSelectedPayer(),
|
||||||
isReimbursement: false,
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
|
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
|
||||||
@@ -138,7 +159,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
|
|||||||
name="expenseDate"
|
name="expenseDate"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="sm:order-1">
|
<FormItem className="sm:order-1">
|
||||||
<FormLabel>Expense Date</FormLabel>
|
<FormLabel>Expense date</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="date-base"
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="paidBy"
|
name="paidBy"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export async function createExpense(
|
|||||||
id: randomId(),
|
id: randomId(),
|
||||||
groupId,
|
groupId,
|
||||||
expenseDate: expenseFormValues.expenseDate,
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
|
categoryId: expenseFormValues.category,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
@@ -124,6 +125,7 @@ export async function updateExpense(
|
|||||||
expenseDate: expenseFormValues.expenseDate,
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
|
categoryId: expenseFormValues.category,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
splitMode: expenseFormValues.splitMode,
|
splitMode: expenseFormValues.splitMode,
|
||||||
paidFor: {
|
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) {
|
export async function getGroupExpenses(groupId: string) {
|
||||||
const prisma = await getPrisma()
|
const prisma = await getPrisma()
|
||||||
return prisma.expense.findMany({
|
return prisma.expense.findMany({
|
||||||
where: { groupId },
|
where: { groupId },
|
||||||
include: { paidFor: { include: { participant: true } }, paidBy: true },
|
include: { paidFor: { include: { participant: true } }, paidBy: true, category: true },
|
||||||
orderBy: { expenseDate: 'desc' },
|
orderBy: { expenseDate: 'desc' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -220,6 +227,6 @@ export async function getExpense(groupId: string, expenseId: string) {
|
|||||||
const prisma = await getPrisma()
|
const prisma = await getPrisma()
|
||||||
return prisma.expense.findUnique({
|
return prisma.expense.findUnique({
|
||||||
where: { id: expenseId },
|
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
|
title: z
|
||||||
.string({ required_error: 'Please enter a title.' })
|
.string({ required_error: 'Please enter a title.' })
|
||||||
.min(2, 'Enter at least two characters.'),
|
.min(2, 'Enter at least two characters.'),
|
||||||
|
category: z.coerce.number().default(0),
|
||||||
amount: z
|
amount: z
|
||||||
.union(
|
.union(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ async function main() {
|
|||||||
amount: Math.round(expenseRow.amount * 100),
|
amount: Math.round(expenseRow.amount * 100),
|
||||||
groupId: groupRow.id,
|
groupId: groupRow.id,
|
||||||
title: expenseRow.description,
|
title: expenseRow.description,
|
||||||
|
categoryId: 1,
|
||||||
expenseDate: new Date(expenseRow.created_at.toDateString()),
|
expenseDate: new Date(expenseRow.created_at.toDateString()),
|
||||||
createdAt: expenseRow.created_at,
|
createdAt: expenseRow.created_at,
|
||||||
isReimbursement: expenseRow.is_reimbursement === true,
|
isReimbursement: expenseRow.is_reimbursement === true,
|
||||||
|
|||||||
Reference in New Issue
Block a user