aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--prisma/migrations/20240108194443_add_categories/migration.sql65
-rw-r--r--prisma/schema.prisma11
-rw-r--r--src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx4
-rw-r--r--src/app/groups/[groupId]/expenses/create/page.tsx5
-rw-r--r--src/components/expense-form.tsx64
-rw-r--r--src/lib/api.ts11
-rw-r--r--src/lib/schemas.ts1
-rw-r--r--src/scripts/migrate.ts1
8 files changed, 153 insertions, 9 deletions
diff --git a/prisma/migrations/20240108194443_add_categories/migration.sql b/prisma/migrations/20240108194443_add_categories/migration.sql
new file mode 100644
index 0000000..bb01af5
--- /dev/null
+++ b/prisma/migrations/20240108194443_add_categories/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 42d4cfc..1fbfc65 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -29,11 +29,20 @@ model Participant {
expensesPaidFor ExpensePaidFor[]
}
+model Category {
+ id Int @id @default(autoincrement())
+ grouping String
+ name String
+ Expense Expense[]
+}
+
model Expense {
id String @id
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
+ category Category? @relation(fields: [categoryId], references: [id])
+ categoryId Int @default(0)
amount Int
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidById String
diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
index 188d08f..bb32da0 100644
--- a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
+++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
@@ -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}
/>
diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx
index e603e59..405db26 100644
--- a/src/app/groups/[groupId]/expenses/create/page.tsx
+++ b/src/app/groups/[groupId]/expenses/create/page.tsx
@@ -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} />
}
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx
index d14d72e..d00211a 100644
--- a/src/components/expense-form.tsx
+++ b/src/components/expense-form.tsx
@@ -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"
@@ -203,6 +224,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"
render={({ field }) => (
<FormItem className="sm:order-5">
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 6ff55b6..2a03ded 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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 },
})
}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index 14b253c..be90481 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -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(
[
diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts
index ed1a3a5..903f119 100644
--- a/src/scripts/migrate.ts
+++ b/src/scripts/migrate.ts
@@ -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,