diff options
| -rw-r--r-- | prisma/migrations/20240108194443_add_categories/migration.sql | 65 | ||||
| -rw-r--r-- | prisma/schema.prisma | 11 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx | 4 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/create/page.tsx | 5 | ||||
| -rw-r--r-- | src/components/expense-form.tsx | 64 | ||||
| -rw-r--r-- | src/lib/api.ts | 11 | ||||
| -rw-r--r-- | src/lib/schemas.ts | 1 | ||||
| -rw-r--r-- | src/scripts/migrate.ts | 1 |
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, |
