From 4decb5e6a3e4f8093a5e3aaf6e4b070d15d7afee Mon Sep 17 00:00:00 2001 From: Sebastien Castiel Date: Fri, 15 Dec 2023 17:00:23 -0500 Subject: [PATCH] Add splitmode and shares to expenses --- package-lock.json | 6 + package.json | 1 + .../migration.sql | 2 + .../migration.sql | 5 + prisma/schema.prisma | 9 + src/components/expense-form.tsx | 155 ++++++++++++++---- src/lib/api.ts | 45 +++-- src/lib/schemas.ts | 8 +- 8 files changed, 190 insertions(+), 41 deletions(-) create mode 100644 prisma/migrations/20231215203936_add_shares_in_expenses/migration.sql create mode 100644 prisma/migrations/20231215213409_add_split_mode/migration.sql diff --git a/package-lock.json b/package-lock.json index 8ffbc2a..ecd5c82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "react-hook-form": "^7.47.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.0.6", "uuid": "^9.0.1", "zod": "^3.22.4" }, @@ -5749,6 +5750,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-pattern": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.6.tgz", + "integrity": "sha512-Y+jOjihlFriWzcBjncPCf2/am+Hgz7LtsWs77pWg5vQQKLQj07oNrJryo/wK2G0ndNaoVn2ownFMeoeAuReu3Q==" + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", diff --git a/package.json b/package.json index 7fe84fc..1c5a53d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-hook-form": "^7.47.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.0.6", "uuid": "^9.0.1", "zod": "^3.22.4" }, diff --git a/prisma/migrations/20231215203936_add_shares_in_expenses/migration.sql b/prisma/migrations/20231215203936_add_shares_in_expenses/migration.sql new file mode 100644 index 0000000..ecf7a04 --- /dev/null +++ b/prisma/migrations/20231215203936_add_shares_in_expenses/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ExpensePaidFor" ADD COLUMN "shares" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/migrations/20231215213409_add_split_mode/migration.sql b/prisma/migrations/20231215213409_add_split_mode/migration.sql new file mode 100644 index 0000000..ac36cfc --- /dev/null +++ b/prisma/migrations/20231215213409_add_split_mode/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "SplitMode" AS ENUM ('EVENLY', 'BY_SHARES', 'BY_PERCENTAGE', 'BY_AMOUNT'); + +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "splitMode" "SplitMode" NOT NULL DEFAULT 'EVENLY'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index edf68f2..fd65754 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,14 +39,23 @@ model Expense { paidFor ExpensePaidFor[] groupId String isReimbursement Boolean @default(false) + splitMode SplitMode @default(EVENLY) createdAt DateTime @default(now()) } +enum SplitMode { + EVENLY + BY_SHARES + BY_PERCENTAGE + BY_AMOUNT +} + model ExpensePaidFor { expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade) expenseId String participantId String + shares Int @default(1) @@id([expenseId, participantId]) } diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx index bdee9d0..b7f6ec4 100644 --- a/src/components/expense-form.tsx +++ b/src/components/expense-form.tsx @@ -32,6 +32,7 @@ import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { zodResolver } from '@hookform/resolvers/zod' import { useSearchParams } from 'next/navigation' import { useForm } from 'react-hook-form' +import { match } from 'ts-pattern' export type Props = { group: NonNullable>> @@ -50,7 +51,11 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { title: expense.title, amount: String(expense.amount / 100) as unknown as number, // hack paidBy: expense.paidById, - paidFor: expense.paidFor.map(({ participantId }) => participantId), + paidFor: expense.paidFor.map(({ participantId, shares }) => ({ + participant: participantId, + shares, + })), + splitMode: expense.splitMode, isReimbursement: expense.isReimbursement, } : searchParams.get('reimbursement') @@ -60,7 +65,11 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { (Number(searchParams.get('amount')) || 0) / 100, ) as unknown as number, // hack paidBy: searchParams.get('from') ?? undefined, - paidFor: [searchParams.get('to') ?? undefined], + paidFor: [ + searchParams.get('to') + ? { participant: searchParams.get('to')!, shares: 1 } + : undefined, + ], isReimbursement: true, } : { title: '', amount: 0, paidFor: [], isReimbursement: false }, @@ -130,7 +139,7 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { control={form.control} name="amount" render={({ field }) => ( - + Amount
{group.currency} @@ -173,6 +182,41 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { name="paidFor" render={() => ( + ( + + Split mode + + + + + Select how to split the expense. + + + )} + /> +
Paid for @@ -184,10 +228,13 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { const paidFor = form.getValues().paidFor const allSelected = paidFor.length === group.participants.length - const newPairFor = allSelected + const newPaidFor = allSelected ? [] - : group.participants.map((p) => p.id) - form.setValue('paidFor', newPairFor, { + : group.participants.map((p) => ({ + participant: p.id, + shares: 1, + })) + form.setValue('paidFor', newPaidFor, { shouldDirty: true, shouldTouch: true, shouldValidate: true, @@ -213,28 +260,80 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) { name="paidFor" render={({ field }) => { return ( - - - { - return checked - ? field.onChange([...field.value, id]) - : field.onChange( - field.value?.filter( - (value) => value !== id, - ), - ) - }} - /> - - - {name} - - +
+ + + participant === id, + )} + onCheckedChange={(checked) => { + return checked + ? field.onChange([ + ...field.value, + { participant: id, shares: 1 }, + ]) + : field.onChange( + field.value?.filter( + (value) => value.participant !== id, + ), + ) + }} + /> + + + {name} + + + {field.value?.some( + ({ participant }) => participant === id, + ) && + form.getValues().splitMode !== 'EVENLY' && ( +
+ + + participant === id, + )?.shares + } + onChange={(event) => + field.onChange( + field.value.map((p) => + p.participant === id + ? { + participant: id, + shares: Number( + event.target.value, + ), + } + : p, + ), + ) + } + inputMode="numeric" + step={1} + /> + + + {match(form.getValues().splitMode) + .with('EVENLY', () => <>) + .with('BY_SHARES', () => <>share(s)) + .with('BY_PERCENTAGE', () => <>%) + .with('BY_AMOUNT', () => ( + <>{group.currency} + )) + .exhaustive()} + +
+ )} +
) }} /> diff --git a/src/lib/api.ts b/src/lib/api.ts index 98419d4..586a354 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -36,7 +36,7 @@ export async function createExpense( for (const participant of [ expenseFormValues.paidBy, - ...expenseFormValues.paidFor, + ...expenseFormValues.paidFor.map((p) => p.participant), ]) { if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) @@ -50,10 +50,12 @@ export async function createExpense( amount: expenseFormValues.amount, title: expenseFormValues.title, paidById: expenseFormValues.paidBy, + splitMode: expenseFormValues.splitMode, paidFor: { createMany: { data: expenseFormValues.paidFor.map((paidFor) => ({ - participantId: paidFor, + participantId: paidFor.participant, + shares: paidFor.shares, })), }, }, @@ -84,12 +86,14 @@ export async function getGroupExpensesParticipants(groupId: string) { export async function getGroups(groupIds: string[]) { const prisma = await getPrisma() - return (await prisma.group.findMany({ - where: { id: { in: groupIds } }, - include: { _count: { select: { participants: true } } }, - })).map(group => ({ + return ( + await prisma.group.findMany({ + where: { id: { in: groupIds } }, + include: { _count: { select: { participants: true } } }, + }) + ).map((group) => ({ ...group, - createdAt: group.createdAt.toISOString() + createdAt: group.createdAt.toISOString(), })) } @@ -106,7 +110,7 @@ export async function updateExpense( for (const participant of [ expenseFormValues.paidBy, - ...expenseFormValues.paidFor, + ...expenseFormValues.paidFor.map((p) => p.participant), ]) { if (!group.participants.some((p) => p.id === participant)) throw new Error(`Invalid participant ID: ${participant}`) @@ -119,17 +123,34 @@ export async function updateExpense( amount: expenseFormValues.amount, title: expenseFormValues.title, paidById: expenseFormValues.paidBy, + splitMode: expenseFormValues.splitMode, paidFor: { - connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({ + create: expenseFormValues.paidFor + .filter( + (p) => + !existingExpense.paidFor.some( + (pp) => pp.participantId === p.participant, + ), + ) + .map((paidFor) => ({ + participantId: paidFor.participant, + shares: paidFor.shares, + })), + update: expenseFormValues.paidFor.map((paidFor) => ({ where: { - expenseId_participantId: { expenseId, participantId: paidFor }, + expenseId_participantId: { + expenseId, + participantId: paidFor.participant, + }, + }, + data: { + shares: paidFor.shares, }, - create: { participantId: paidFor }, })), deleteMany: existingExpense.paidFor.filter( (paidFor) => !expenseFormValues.paidFor.some( - (pf) => pf === paidFor.participantId, + (pf) => pf.participant === paidFor.participantId, ), ), }, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 0bebae6..72d132e 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -1,3 +1,4 @@ +import { SplitMode } from '@prisma/client' import * as z from 'zod' export const groupFormSchema = z @@ -65,8 +66,13 @@ export const expenseFormSchema = z.object({ ), paidBy: z.string({ required_error: 'You must select a participant.' }), paidFor: z - .array(z.string()) + .array(z.object({ participant: z.string(), shares: z.number().int() })) .min(1, 'The expense must be paid for at least one participant.'), + splitMode: z + .enum( + Object.values(SplitMode) as any, + ) + .default('EVENLY'), isReimbursement: z.boolean(), })