8 Commits
1.7.0 ... 1.8.0

Author SHA1 Message Date
Stefan Hynst
0c05499107 Add support for group income (= negative expenses) (#158)
* Allow negative amount for expenses to be entered

- an expense becomes an income
- this does not affect calculations, i.e. an income can be split just like an expense

* Incomes should not be reimbursements

when entering a negative number
- deselect 'isReimbursement'
- hide reimbursement checkbox

* Change captions when entering a negative number

- "expense" becomes "income"
- "paid" becomes "received"

* Format incomes on expense list

- replace "paid by" with "received by"

* Format incomes on "Stats" tab

- a group's or participants balance might be negative
- in this case "spendings" will be "earnings" (display accordingly)
- always display positive numbers
- for active user: highlight spendings/earnings in red/green

* Fix typo

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:20:04 -04:00
Oliver Wong
3887efd9ee Use placeholder for new participant input (#153)
* use placeholder for new participant

* Fix formatting

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:11:24 -04:00
dcbr
e619c1a5b4 Add basic activity log (#141)
* Add basic activity log

* Add database migration

* Fix layout

* Fix types

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:06:45 -04:00
Lauri Vuorela
10e13d1f6b copy the next.config.js in order to get custom domains working again (#147) 2024-05-29 21:46:16 -04:00
Lauri Vuorela
f9d915378b change onClick to onFocus, with a slight delay for safari (#144)
* change onClick to onFocus, with a slight delay for safari

* typo

* fix variable name

* Fix style

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:45:46 -04:00
Antonin RAFFIN
74465c0565 Fix relative path docker db script (#154)
Without this, docker complained:
```
docker: Error response from daemon: create ./postgres-data: "./postgres-data" includes invalid characters for a local volume name, only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed. If you intended to pass a host directory, use absolute path.
```

Followed recommendation from https://stackoverflow.com/questions/46526165/docker-invalid-characters-for-volume-when-using-relative-paths
2024-05-29 21:38:37 -04:00
Stefan Hynst
d3fd8027a5 Implement "infinite scroll" for expenses (#95)
* Extract ExpenseCard vom ExpenseList

* Implement simple pagination of expenses (see #30)

- display only this year's entries by default
- a "Show more" button reveals all expenses

* Turn getPrisma() into constant "prisma"

- getPrisma() is not async and doesn't need to be awaited
- turn getPrisma() into exported constant "prisma"

* Select fields to be returned by getGroupExpenses()

- make JSON more concise and less redundant
- some properties were removed (i.e.instead of "expense.paidById" use "expense.paidBy.id")

* Remove "participants" from ExpenseCard

- no need to search for participant by id to get it's name
- name property is already present in expense

* Add option to fetch a slice of group expenses

- specify offset and length to get expenses for [offset, offset+length[
- add function to get total number of group expenses

* Add api route for client to fetch group expenses

* Remove "Show more" button from expense list

* Implement infinite scroll

- in server component Page
  - only load first 200 expenses max
  - pass preloaded expenses and total count

- in client component ExpenseList, if there are more expenses to show
  - test if there are more expenses
  - append preloading "skeletons" to end of list
  - fetch more expenses when last item in list comes into view
  - after each fetch increase fetch-length by factor 1.5
    - rationale: db fetch usually is not the issue here, the longer the list gets, the longer react needs to redraw

* Use server action instead of api endpoint

* Fixes

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:36:07 -04:00
Stefan Hynst
833237b613 Add "x" button to cancel search in search bar (#107) 2024-05-29 21:26:04 -04:00
34 changed files with 781 additions and 238 deletions

View File

@@ -27,7 +27,7 @@ RUN rm -r .next/cache
FROM node:21-alpine as runtime-deps FROM node:21-alpine as runtime-deps
WORKDIR /usr/app WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./ COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=base /usr/app/prisma ./prisma COPY --from=base /usr/app/prisma ./prisma
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \ RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
@@ -38,7 +38,7 @@ FROM node:21-alpine as runner
EXPOSE 3000/tcp EXPOSE 3000/tcp
WORKDIR /usr/app WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./ COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=runtime-deps /usr/app/node_modules ./node_modules COPY --from=runtime-deps /usr/app/node_modules ./node_modules
COPY ./public ./public COPY ./public ./public
COPY ./scripts ./scripts COPY ./scripts ./scripts

15
package-lock.json generated
View File

@@ -43,6 +43,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -7510,6 +7511,20 @@
"react": "^16.8.0 || ^17 || ^18" "react": "^16.8.0 || ^17 || ^18"
} }
}, },
"node_modules/react-intersection-observer": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.8.0.tgz",
"integrity": "sha512-wXHvMQUsTagh3X0Z6jDtGkIXc3VVCd2tjDRYR9kII3GKrZr0XF0xtpfdamo2n8BSF+zzfeeBVOTjxZWpBp9X0g==",
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -48,6 +48,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE');
-- CreateTable
CREATE TABLE "Activity" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"activityType" "ActivityType" NOT NULL,
"participantId" TEXT,
"expenseId" TEXT,
"data" TEXT,
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -17,6 +17,7 @@ model Group {
currency String @default("$") currency String @default("$")
participants Participant[] participants Participant[]
expenses Expense[] expenses Expense[]
activities Activity[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
@@ -80,3 +81,21 @@ model ExpensePaidFor {
@@id([expenseId, participantId]) @@id([expenseId, participantId])
} }
model Activity {
id String @id
group Group @relation(fields: [groupId], references: [id])
groupId String
time DateTime @default(now())
activityType ActivityType
participantId String?
expenseId String?
data String?
}
enum ActivityType {
UPDATE_GROUP
CREATE_EXPENSE
UPDATE_EXPENSE
DELETE_EXPENSE
}

View File

@@ -6,6 +6,6 @@ else
echo "postgres is not running, starting it" echo "postgres is not running, starting it"
docker rm postgres --force docker rm postgres --force
mkdir -p postgres-data mkdir -p postgres-data
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v ./postgres-data:/var/lib/postgresql/data postgres docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres
sleep 5 # Wait for postgres to start sleep 5 # Wait for postgres to start
fi fi

View File

@@ -0,0 +1,102 @@
'use client'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
import { Activity, ActivityType, Participant } from '@prisma/client'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
type Props = {
groupId: string
activity: Activity
participant?: Participant
expense?: Awaited<ReturnType<typeof getGroupExpenses>>[number]
dateStyle: DateTimeStyle
}
function getSummary(activity: Activity, participantName?: string) {
const participant = participantName ?? 'Someone'
const expense = activity.data ?? ''
if (activity.activityType == ActivityType.UPDATE_GROUP) {
return (
<>
Group settings were modified by <strong>{participant}</strong>
</>
)
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> created by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> updated by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> deleted by{' '}
<strong>{participant}</strong>.
</>
)
}
}
export function ActivityItem({
groupId,
activity,
participant,
expense,
dateStyle,
}: Props) {
const router = useRouter()
const expenseExists = expense !== undefined
const summary = getSummary(activity, participant?.name)
return (
<div
className={cn(
'flex justify-between sm:rounded-lg px-2 sm:pr-1 sm:pl-2 py-2 text-sm hover:bg-accent gap-1 items-stretch',
expenseExists && 'cursor-pointer',
)}
onClick={() => {
if (expenseExists) {
router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`)
}
}}
>
<div className="flex flex-col justify-between items-start">
{dateStyle !== undefined && (
<div className="mt-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { dateStyle })}
</div>
)}
<div className="my-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { timeStyle: 'short' })}
</div>
</div>
<div className="flex-1">
<div className="m-1">{summary}</div>
</div>
{expenseExists && (
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex w-5 h-5"
asChild
>
<Link href={`/groups/${groupId}/expenses/${activity.expenseId}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
import { getGroupExpenses } from '@/lib/api'
import { Activity, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
type Props = {
groupId: string
participants: Participant[]
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
activities: Activity[]
}
const DATE_GROUPS = {
TODAY: 'Today',
YESTERDAY: 'Yesterday',
EARLIER_THIS_WEEK: 'Earlier this week',
LAST_WEEK: 'Last week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
EARLIER_THIS_YEAR: 'Earlier this year',
LAST_YEAR: 'Last year',
OLDER: 'Older',
}
function getDateGroup(date: Dayjs, today: Dayjs) {
if (today.isSame(date, 'day')) {
return DATE_GROUPS.TODAY
} else if (today.subtract(1, 'day').isSame(date, 'day')) {
return DATE_GROUPS.YESTERDAY
} else if (today.isSame(date, 'week')) {
return DATE_GROUPS.EARLIER_THIS_WEEK
} else if (today.subtract(1, 'week').isSame(date, 'week')) {
return DATE_GROUPS.LAST_WEEK
} else if (today.isSame(date, 'month')) {
return DATE_GROUPS.EARLIER_THIS_MONTH
} else if (today.subtract(1, 'month').isSame(date, 'month')) {
return DATE_GROUPS.LAST_MONTH
} else if (today.isSame(date, 'year')) {
return DATE_GROUPS.EARLIER_THIS_YEAR
} else if (today.subtract(1, 'year').isSame(date, 'year')) {
return DATE_GROUPS.LAST_YEAR
} else {
return DATE_GROUPS.OLDER
}
}
function getGroupedActivitiesByDate(activities: Activity[]) {
const today = dayjs()
return activities.reduce(
(result: { [key: string]: Activity[] }, activity: Activity) => {
const activityGroup = getDateGroup(dayjs(activity.time), today)
result[activityGroup] = result[activityGroup] ?? []
result[activityGroup].push(activity)
return result
},
{},
)
}
export function ActivityList({
groupId,
participants,
expenses,
activities,
}: Props) {
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
return activities.length > 0 ? (
<>
{Object.values(DATE_GROUPS).map((dateGroup: string) => {
let groupActivities = groupedActivitiesByDate[dateGroup]
if (!groupActivities || groupActivities.length === 0) return null
const dateStyle =
dateGroup == DATE_GROUPS.TODAY || dateGroup == DATE_GROUPS.YESTERDAY
? undefined
: 'medium'
return (
<div key={dateGroup}>
<div
className={
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
}
>
{dateGroup}
</div>
{groupActivities.map((activity: Activity) => {
const participant =
activity.participantId !== null
? participants.find((p) => p.id === activity.participantId)
: undefined
const expense =
activity.expenseId !== null
? expenses.find((e) => e.id === activity.expenseId)
: undefined
return (
<ActivityItem
key={activity.id}
{...{ groupId, activity, participant, expense, dateStyle }}
/>
)
})}
</div>
)
})}
</>
) : (
<p className="px-6 text-sm py-6">
There is not yet any activity in your group.
</p>
)
}

View File

@@ -0,0 +1,51 @@
import { cached } from '@/app/cached-functions'
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getActivities, getGroupExpenses } from '@/lib/api'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Activity',
}
export default async function ActivityPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const activities = await getActivities(groupId)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Overview of all activity in this group.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList
{...{
groupId,
participants: group.participants,
expenses,
activities,
}}
/>
</CardContent>
</Card>
</>
)
}

View File

@@ -17,10 +17,10 @@ export default async function EditGroupPage({
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
async function updateGroupAction(values: unknown) { async function updateGroupAction(values: unknown, participantId?: string) {
'use server' 'use server'
const groupFormValues = groupFormSchema.parse(values) const groupFormValues = groupFormSchema.parse(values)
const group = await updateGroup(groupId, groupFormValues) const group = await updateGroup(groupId, groupFormValues, participantId)
redirect(`/groups/${group.id}`) redirect(`/groups/${group.id}`)
} }

View File

@@ -27,16 +27,16 @@ export default async function EditExpensePage({
const expense = await getExpense(groupId, expenseId) const expense = await getExpense(groupId, expenseId)
if (!expense) notFound() if (!expense) notFound()
async function updateExpenseAction(values: unknown) { async function updateExpenseAction(values: unknown, participantId?: string) {
'use server' 'use server'
const expenseFormValues = expenseFormSchema.parse(values) const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues) await updateExpense(groupId, expenseId, expenseFormValues, participantId)
redirect(`/groups/${groupId}`) redirect(`/groups/${groupId}`)
} }
async function deleteExpenseAction() { async function deleteExpenseAction(participantId?: string) {
'use server' 'use server'
await deleteExpense(expenseId) await deleteExpense(groupId, expenseId, participantId)
redirect(`/groups/${groupId}`) redirect(`/groups/${groupId}`)
} }

View File

@@ -26,7 +26,7 @@ import {
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useMediaQuery } from '@/lib/hooks' import { useMediaQuery } from '@/lib/hooks'
import { formatCurrency, formatExpenseDate, formatFileSize } from '@/lib/utils' import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
import { Category } from '@prisma/client' import { Category } from '@prisma/client'
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react' import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { getImageData, usePresignedUpload } from 'next-s3-upload' import { getImageData, usePresignedUpload } from 'next-s3-upload'
@@ -212,9 +212,9 @@ export function CreateFromReceiptButton({
<div> <div>
{receiptInfo ? ( {receiptInfo ? (
receiptInfo.date ? ( receiptInfo.date ? (
formatExpenseDate( formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), {
new Date(`${receiptInfo?.date}T12:00:00.000Z`), dateStyle: 'medium',
) })
) : ( ) : (
<Unknown /> <Unknown />
) )

View File

@@ -20,10 +20,10 @@ export default async function ExpensePage({
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
async function createExpenseAction(values: unknown) { async function createExpenseAction(values: unknown, participantId?: string) {
'use server' 'use server'
const expenseFormValues = expenseFormSchema.parse(values) const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId) await createExpense(expenseFormValues, groupId, participantId)
redirect(`/groups/${groupId}`) redirect(`/groups/${groupId}`)
} }

View File

@@ -0,0 +1,79 @@
'use client'
import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance'
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Fragment } from 'react'
type Props = {
expense: Awaited<ReturnType<typeof getGroupExpenses>>[number]
currency: string
groupId: string
}
export function ExpenseCard({ expense, currency, groupId }: Props) {
const router = useRouter()
return (
<div
key={expense.id}
className={cn(
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
expense.isReimbursement && 'italic',
)}
onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
}}
>
<CategoryIcon
category={expense.category}
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
/>
<div className="flex-1">
<div className={cn('mb-1', expense.isReimbursement && 'italic')}>
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
{expense.amount > 0 ? 'Paid by ' : 'Received by '}
<strong>{expense.paidBy.name}</strong> for{' '}
{expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))}
</div>
<div className="text-xs text-muted-foreground">
<ActiveUserBalance {...{ groupId, currency, expense }} />
</div>
</div>
<div className="flex flex-col justify-between items-end">
<div
className={cn(
'tabular-nums whitespace-nowrap',
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{formatCurrency(currency, expense.amount)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(expense.expenseDate, { dateStyle: 'medium' })}
</div>
</div>
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex"
asChild
>
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,16 @@
'use server'
import { getGroupExpenses } from '@/lib/api'
export async function getGroupExpensesAction(
groupId: string,
options?: { offset: number; length: number },
) {
'use server'
try {
return getGroupExpenses(groupId, options)
} catch {
return null
}
}

View File

@@ -1,19 +1,22 @@
'use client' 'use client'
import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance' import { ExpenseCard } from '@/app/groups/[groupId]/expenses/expense-card'
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-list-fetch-action'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar' import { SearchBar } from '@/components/ui/search-bar'
import { getGroupExpenses } from '@/lib/api' import { Skeleton } from '@/components/ui/skeleton'
import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils' import { Participant } from '@prisma/client'
import { Expense, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs' import dayjs, { type Dayjs } from 'dayjs'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useState } from 'react' import { useInView } from 'react-intersection-observer'
type ExpensesType = NonNullable<
Awaited<ReturnType<typeof getGroupExpensesAction>>
>
type Props = { type Props = {
expenses: Awaited<ReturnType<typeof getGroupExpenses>> expensesFirstPage: ExpensesType
expenseCount: number
participants: Participant[] participants: Participant[]
currency: string currency: string
groupId: string groupId: string
@@ -47,28 +50,32 @@ function getExpenseGroup(date: Dayjs, today: Dayjs) {
} }
} }
function getGroupedExpensesByDate( function getGroupedExpensesByDate(expenses: ExpensesType) {
expenses: Awaited<ReturnType<typeof getGroupExpenses>>,
) {
const today = dayjs() const today = dayjs()
return expenses.reduce( return expenses.reduce((result: { [key: string]: ExpensesType }, expense) => {
(result: { [key: string]: Expense[] }, expense: Expense) => { const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today) result[expenseGroup] = result[expenseGroup] ?? []
result[expenseGroup] = result[expenseGroup] ?? [] result[expenseGroup].push(expense)
result[expenseGroup].push(expense) return result
return result }, {})
},
{},
)
} }
export function ExpenseList({ export function ExpenseList({
expenses, expensesFirstPage,
expenseCount,
currency, currency,
participants, participants,
groupId, groupId,
}: Props) { }: Props) {
const firstLen = expensesFirstPage.length
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [dataIndex, setDataIndex] = useState(firstLen)
const [dataLen, setDataLen] = useState(firstLen)
const [hasMoreData, setHasMoreData] = useState(expenseCount > firstLen)
const [isFetching, setIsFetching] = useState(false)
const [expenses, setExpenses] = useState(expensesFirstPage)
const { ref, inView } = useInView()
useEffect(() => { useEffect(() => {
const activeUser = localStorage.getItem('newGroup-activeUser') const activeUser = localStorage.getItem('newGroup-activeUser')
const newUser = localStorage.getItem(`${groupId}-newUser`) const newUser = localStorage.getItem(`${groupId}-newUser`)
@@ -88,13 +95,46 @@ export function ExpenseList({
} }
}, [groupId, participants]) }, [groupId, participants])
const getParticipant = (id: string) => participants.find((p) => p.id === id) useEffect(() => {
const router = useRouter() const fetchNextPage = async () => {
setIsFetching(true)
const newExpenses = await getGroupExpensesAction(groupId, {
offset: dataIndex,
length: dataLen,
})
if (newExpenses !== null) {
const exp = expenses.concat(newExpenses)
setExpenses(exp)
setHasMoreData(exp.length < expenseCount)
setDataIndex(dataIndex + dataLen)
setDataLen(Math.ceil(1.5 * dataLen))
}
setTimeout(() => setIsFetching(false), 500)
}
if (inView && hasMoreData && !isFetching) fetchNextPage()
}, [
dataIndex,
dataLen,
expenseCount,
expenses,
groupId,
hasMoreData,
inView,
isFetching,
])
const groupedExpensesByDate = useMemo(
() => getGroupedExpensesByDate(expenses),
[expenses],
)
const groupedExpensesByDate = getGroupedExpensesByDate(expenses)
return expenses.length > 0 ? ( return expenses.length > 0 ? (
<> <>
<SearchBar onChange={(e) => setSearchText(e.target.value)} /> <SearchBar onValueChange={(value) => setSearchText(value)} />
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => { {Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup] let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null if (!groupExpenses) return null
@@ -114,76 +154,33 @@ export function ExpenseList({
> >
{expenseGroup} {expenseGroup}
</div> </div>
{groupExpenses.map((expense: any) => ( {groupExpenses.map((expense) => (
<div <ExpenseCard
key={expense.id} key={expense.id}
className={cn( expense={expense}
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch', currency={currency}
expense.isReimbursement && 'italic', groupId={groupId}
)} />
onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
}}
>
<CategoryIcon
category={expense.category}
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
/>
<div className="flex-1">
<div
className={cn('mb-1', expense.isReimbursement && 'italic')}
>
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
Paid by{' '}
<strong>{getParticipant(expense.paidById)?.name}</strong>{' '}
for{' '}
{expense.paidFor.map((paidFor: any, index: number) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>
{
participants.find(
(p) => p.id === paidFor.participantId,
)?.name
}
</strong>
</Fragment>
))}
</div>
<div className="text-xs text-muted-foreground">
<ActiveUserBalance {...{ groupId, currency, expense }} />
</div>
</div>
<div className="flex flex-col justify-between items-end">
<div
className={cn(
'tabular-nums whitespace-nowrap',
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{formatCurrency(currency, expense.amount)}
</div>
<div className="text-xs text-muted-foreground">
{formatExpenseDate(expense.expenseDate)}
</div>
</div>
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex"
asChild
>
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
</div>
))} ))}
</div> </div>
) )
})} })}
{expenses.length < expenseCount &&
[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
ref={i === 0 ? ref : undefined}
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
</> </>
) : ( ) : (
<p className="px-6 text-sm py-6"> <p className="px-6 text-sm py-6">

View File

@@ -1,4 +1,4 @@
import { getPrisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import contentDisposition from 'content-disposition' import contentDisposition from 'content-disposition'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
@@ -6,7 +6,6 @@ export async function GET(
req: Request, req: Request,
{ params: { groupId } }: { params: { groupId: string } }, { params: { groupId } }: { params: { groupId: string } },
) { ) {
const prisma = await getPrisma()
const group = await prisma.group.findUnique({ const group = await prisma.group.findUnique({
where: { id: groupId }, where: { id: groupId },
select: { select: {

View File

@@ -11,7 +11,11 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { getCategories, getGroupExpenses } from '@/lib/api' import {
getCategories,
getGroupExpenseCount,
getGroupExpenses,
} from '@/lib/api'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react' import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next' import { Metadata } from 'next'
@@ -91,7 +95,7 @@ export default async function GroupExpensesPage({
</div> </div>
))} ))}
> >
<Expenses groupId={groupId} /> <Expenses group={group} />
</Suspense> </Suspense>
</CardContent> </CardContent>
</Card> </Card>
@@ -101,14 +105,22 @@ export default async function GroupExpensesPage({
) )
} }
async function Expenses({ groupId }: { groupId: string }) { type Props = {
const group = await cached.getGroup(groupId) group: NonNullable<Awaited<ReturnType<typeof cached.getGroup>>>
if (!group) notFound() }
const expenses = await getGroupExpenses(group.id)
async function Expenses({ group }: Props) {
const expenseCount = await getGroupExpenseCount(group.id)
const expenses = await getGroupExpenses(group.id, {
offset: 0,
length: 200,
})
return ( return (
<ExpenseList <ExpenseList
expenses={expenses} expensesFirstPage={expenses}
expenseCount={expenseCount}
groupId={group.id} groupId={group.id}
currency={group.currency} currency={group.currency}
participants={group.participants} participants={group.participants}

View File

@@ -15,7 +15,7 @@ export function GroupTabs({ groupId }: Props) {
return ( return (
<Tabs <Tabs
value={value} value={value}
className="[&>*]:border" className="[&>*]:border overflow-x-auto"
onValueChange={(value) => { onValueChange={(value) => {
router.push(`/groups/${groupId}/${value}`) router.push(`/groups/${groupId}/${value}`)
}} }}
@@ -24,6 +24,7 @@ export function GroupTabs({ groupId }: Props) {
<TabsTrigger value="expenses">Expenses</TabsTrigger> <TabsTrigger value="expenses">Expenses</TabsTrigger>
<TabsTrigger value="balances">Balances</TabsTrigger> <TabsTrigger value="balances">Balances</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger> <TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="edit">Settings</TabsTrigger> <TabsTrigger value="edit">Settings</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View File

@@ -35,7 +35,7 @@ export default async function GroupLayout({
return ( return (
<> <>
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3"> <div className="flex flex-col justify-between gap-3">
<h1 className="font-bold text-2xl"> <h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>{group.name}</Link> <Link href={`/groups/${groupId}`}>{group.name}</Link>
</h1> </h1>

View File

@@ -23,7 +23,7 @@ export function ShareButton({ group }: Props) {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button title="Share" size="icon"> <Button title="Share" size="icon" className="flex-shrink-0">
<Share className="w-4 h-4" /> <Share className="w-4 h-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -6,11 +6,12 @@ type Props = {
} }
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) { export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
const balance = totalGroupSpendings < 0 ? 'earnings' : 'spendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Total group spendings</div> <div className="text-muted-foreground">Total group {balance}</div>
<div className="text-lg"> <div className="text-lg">
{formatCurrency(currency, totalGroupSpendings)} {formatCurrency(currency, Math.abs(totalGroupSpendings))}
</div> </div>
</div> </div>
) )

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { getGroup, getGroupExpenses } from '@/lib/api' import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals' import { getTotalActiveUserShare } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
type Props = { type Props = {
@@ -26,8 +26,13 @@ export function TotalsYourShare({ group, expenses }: Props) {
return ( return (
<div> <div>
<div className="text-muted-foreground">Your total share</div> <div className="text-muted-foreground">Your total share</div>
<div className="text-lg"> <div
{formatCurrency(currency, totalActiveUserShare)} className={cn(
'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalActiveUserShare))}
</div> </div>
</div> </div>
) )

View File

@@ -2,7 +2,7 @@
import { getGroup, getGroupExpenses } from '@/lib/api' import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks' import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals' import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
type Props = { type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>> group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -17,13 +17,19 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
? 0 ? 0
: getTotalActiveUserPaidFor(activeUser, expenses) : getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency const currency = group.currency
const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Total you paid for</div> <div className="text-muted-foreground">Your total {balance}</div>
<div className="text-lg"> <div
{formatCurrency(currency, totalYourSpendings)} className={cn(
'text-lg',
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalYourSpendings))}
</div> </div>
</div> </div>
) )

View File

@@ -35,6 +35,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api' import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags' import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks'
import { import {
ExpenseFormValues, ExpenseFormValues,
SplittingOptions, SplittingOptions,
@@ -56,21 +57,22 @@ 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>>> categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues) => Promise<void> onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: () => Promise<void> onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags runtimeFeatureFlags: RuntimeFeatureFlags
} }
const enforceCurrencyPattern = (value: string) => const enforceCurrencyPattern = (value: string) =>
value value
// replace first comma with # .replace(/^\s*-/, '_') // replace leading minus with _
.replace(/[.,]/, '#') .replace(/[.,]/, '#') // replace first comma with #
// remove all other commas .replace(/[-.,]/g, '') // remove other minus and commas characters
.replace(/[.,]/g, '') .replace(/_/, '-') // change back _ to minus
// change back # to dot .replace(/#/, '.') // change back # to dot
.replace(/#/, '.') .replace(/[^-\d.]/g, '') // remove all non-numeric characters
// remove all non-numeric and non-dot characters
.replace(/[^\d.]/g, '') const capitalize = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1)
const getDefaultSplittingOptions = (group: Props['group']) => { const getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = { const defaultValue = {
@@ -235,20 +237,23 @@ export function ExpenseForm({
}, },
}) })
const [isCategoryLoading, setCategoryLoading] = useState(false) const [isCategoryLoading, setCategoryLoading] = useState(false)
const activeUserId = useActiveUser(group.id)
const submit = async (values: ExpenseFormValues) => { const submit = async (values: ExpenseFormValues) => {
await persistDefaultSplittingOptions(group.id, values) await persistDefaultSplittingOptions(group.id, values)
return onSubmit(values) return onSubmit(values, activeUserId ?? undefined)
} }
const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0)
const sExpense = isIncome ? 'income' : 'expense'
const sPaid = isIncome ? 'received' : 'paid'
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(submit)}> <form onSubmit={form.handleSubmit(submit)}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6"> <CardContent className="grid sm:grid-cols-2 gap-6">
<FormField <FormField
@@ -256,7 +261,7 @@ export function ExpenseForm({
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem className=""> <FormItem className="">
<FormLabel>Expense title</FormLabel> <FormLabel>{capitalize(sExpense)} title</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Monday evening restaurant" placeholder="Monday evening restaurant"
@@ -276,7 +281,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter a description for the expense. Enter a description for the {sExpense}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -288,7 +293,7 @@ export function ExpenseForm({
name="expenseDate" name="expenseDate"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-1"> <FormItem className="sm:order-1">
<FormLabel>Expense date</FormLabel> <FormLabel>{capitalize(sExpense)} date</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="date-base" className="date-base"
@@ -300,7 +305,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter the date the expense was made. Enter the date the {sExpense} was {sPaid}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -317,39 +322,47 @@ export function ExpenseForm({
<span>{group.currency}</span> <span>{group.currency}</span>
<FormControl> <FormControl>
<Input <Input
{...field}
className="text-base max-w-[120px]" className="text-base max-w-[120px]"
type="text" type="text"
inputMode="decimal" inputMode="decimal"
step={0.01}
placeholder="0.00" placeholder="0.00"
onChange={(event) => onChange={(event) => {
onChange(enforceCurrencyPattern(event.target.value)) const v = enforceCurrencyPattern(event.target.value)
} const income = Number(v) < 0
onClick={(e) => e.currentTarget.select()} setIsIncome(income)
if (income) form.setValue('isReimbursement', false)
onChange(v)
}}
onFocus={(e) => {
// we're adding a small delay to get around safaris issue with onMouseUp deselecting things again
const target = e.currentTarget
setTimeout(() => target.select(), 1)
}}
{...field} {...field}
/> />
</FormControl> </FormControl>
</div> </div>
<FormMessage /> <FormMessage />
<FormField {!isIncome && (
control={form.control} <FormField
name="isReimbursement" control={form.control}
render={({ field }) => ( name="isReimbursement"
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2"> render={({ field }) => (
<FormControl> <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<Checkbox <FormControl>
checked={field.value} <Checkbox
onCheckedChange={field.onChange} checked={field.value}
/> onCheckedChange={field.onChange}
</FormControl> />
<div> </FormControl>
<FormLabel>This is a reimbursement</FormLabel> <div>
</div> <FormLabel>This is a reimbursement</FormLabel>
</FormItem> </div>
)} </FormItem>
/> )}
/>
)}
</FormItem> </FormItem>
)} )}
/> />
@@ -369,7 +382,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading} isLoading={isCategoryLoading}
/> />
<FormDescription> <FormDescription>
Select the expense category. Select the {sExpense} category.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -381,7 +394,7 @@ export function ExpenseForm({
name="paidBy" name="paidBy"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-5"> <FormItem className="sm:order-5">
<FormLabel>Paid by</FormLabel> <FormLabel>{capitalize(sPaid)} by</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)} defaultValue={getSelectedPayer(field)}
@@ -398,7 +411,7 @@ export function ExpenseForm({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
Select the participant who paid the expense. Select the participant who {sPaid} the {sExpense}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -422,7 +435,7 @@ export function ExpenseForm({
<Card className="mt-4"> <Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="flex justify-between"> <CardTitle className="flex justify-between">
<span>Paid for</span> <span>{capitalize(sPaid)} for</span>
<Button <Button
variant="link" variant="link"
type="button" type="button"
@@ -455,7 +468,7 @@ export function ExpenseForm({
</Button> </Button>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Select who the expense was paid for. Select who the {sExpense} was {sPaid} for.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -655,7 +668,7 @@ export function ExpenseForm({
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Select how to split the expense. Select how to split the {sExpense}.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@@ -692,7 +705,7 @@ export function ExpenseForm({
<span>Attach documents</span> <span>Attach documents</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
See and attach receipts to the expense. See and attach receipts to the {sExpense}.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -718,7 +731,9 @@ export function ExpenseForm({
{isCreate ? <>Create</> : <>Save</>} {isCreate ? <>Create</> : <>Save</>}
</SubmitButton> </SubmitButton>
{!isCreate && onDelete && ( {!isCreate && onDelete && (
<DeletePopup onDelete={onDelete}></DeletePopup> <DeletePopup
onDelete={() => onDelete(activeUserId ?? undefined)}
></DeletePopup>
)} )}
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link> <Link href={`/groups/${group.id}`}>Cancel</Link>

View File

@@ -41,7 +41,10 @@ import { useFieldArray, useForm } from 'react-hook-form'
export type Props = { export type Props = {
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>> group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
onSubmit: (groupFormValues: GroupFormValues) => Promise<void> onSubmit: (
groupFormValues: GroupFormValues,
participantId?: string,
) => Promise<void>
protectedParticipantIds?: string[] protectedParticipantIds?: string[]
} }
@@ -99,7 +102,11 @@ export function GroupForm({
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
await onSubmit(values) await onSubmit(
values,
group?.participants.find((p) => p.name === activeUser)?.id ??
undefined,
)
})} })}
> >
<Card className="mb-4"> <Card className="mb-4">
@@ -173,7 +180,11 @@ export function GroupForm({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<div className="flex gap-2"> <div className="flex gap-2">
<Input className="text-base" {...field} /> <Input
className="text-base"
{...field}
placeholder="New"
/>
{item.id && {item.id &&
protectedParticipantIds.includes(item.id) ? ( protectedParticipantIds.includes(item.id) ? (
<HoverCard> <HoverCard>
@@ -221,7 +232,7 @@ export function GroupForm({
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
append({ name: 'New' }) append({ name: '' })
}} }}
type="button" type="button"
> >

View File

@@ -1,33 +1,49 @@
import * as React from "react" import * as React from 'react'
import {Input} from "@/components/ui/input"; import { Input } from '@/components/ui/input'
import {cn} from "@/lib/utils"; import { cn } from '@/lib/utils'
import { import { Search, XCircle } from 'lucide-react'
Search
} from 'lucide-react'
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void
}
const SearchBar = React.forwardRef<HTMLInputElement, InputProps>( const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, onValueChange, ...props }, ref) => {
const [value, _setValue] = React.useState('')
const setValue = (v: string) => {
_setValue(v)
onValueChange && onValueChange(v)
}
return ( return (
<div className="mx-4 sm:mx-6 flex relative"> <div className="mx-4 sm:mx-6 flex relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
type={type} type={type}
className={cn( className={cn(
"pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground", 'pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground',
className className,
)} )}
ref={ref} ref={ref}
placeholder="Search for an expense…" placeholder="Search for an expense…"
value={value}
onChange={(e) => setValue(e.target.value)}
{...props} {...props}
/> />
<XCircle
className={cn(
'absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 cursor-pointer',
!value && 'hidden',
)}
onClick={() => setValue('')}
/>
</div> </div>
) )
} },
) )
SearchBar.displayName = "SearchBar" SearchBar.displayName = 'SearchBar'
export { SearchBar } export { SearchBar }

View File

@@ -1,6 +1,6 @@
import { getPrisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas' import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
import { Expense } from '@prisma/client' import { ActivityType, Expense } from '@prisma/client'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
export function randomId() { export function randomId() {
@@ -8,7 +8,6 @@ export function randomId() {
} }
export async function createGroup(groupFormValues: GroupFormValues) { export async function createGroup(groupFormValues: GroupFormValues) {
const prisma = await getPrisma()
return prisma.group.create({ return prisma.group.create({
data: { data: {
id: randomId(), id: randomId(),
@@ -30,6 +29,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
export async function createExpense( export async function createExpense(
expenseFormValues: ExpenseFormValues, expenseFormValues: ExpenseFormValues,
groupId: string, groupId: string,
participantId?: string,
): Promise<Expense> { ): Promise<Expense> {
const group = await getGroup(groupId) const group = await getGroup(groupId)
if (!group) throw new Error(`Invalid group ID: ${groupId}`) if (!group) throw new Error(`Invalid group ID: ${groupId}`)
@@ -42,10 +42,16 @@ export async function createExpense(
throw new Error(`Invalid participant ID: ${participant}`) throw new Error(`Invalid participant ID: ${participant}`)
} }
const prisma = await getPrisma() const expenseId = randomId()
await logActivity(groupId, ActivityType.CREATE_EXPENSE, {
participantId,
expenseId,
data: expenseFormValues.title,
})
return prisma.expense.create({ return prisma.expense.create({
data: { data: {
id: randomId(), id: expenseId,
groupId, groupId,
expenseDate: expenseFormValues.expenseDate, expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category, categoryId: expenseFormValues.category,
@@ -77,8 +83,18 @@ export async function createExpense(
}) })
} }
export async function deleteExpense(expenseId: string) { export async function deleteExpense(
const prisma = await getPrisma() groupId: string,
expenseId: string,
participantId?: string,
) {
const existingExpense = await getExpense(groupId, expenseId)
await logActivity(groupId, ActivityType.DELETE_EXPENSE, {
participantId,
expenseId,
data: existingExpense?.title,
})
await prisma.expense.delete({ await prisma.expense.delete({
where: { id: expenseId }, where: { id: expenseId },
include: { paidFor: true, paidBy: true }, include: { paidFor: true, paidBy: true },
@@ -90,15 +106,14 @@ export async function getGroupExpensesParticipants(groupId: string) {
return Array.from( return Array.from(
new Set( new Set(
expenses.flatMap((e) => [ expenses.flatMap((e) => [
e.paidById, e.paidBy.id,
...e.paidFor.map((pf) => pf.participantId), ...e.paidFor.map((pf) => pf.participant.id),
]), ]),
), ),
) )
} }
export async function getGroups(groupIds: string[]) { export async function getGroups(groupIds: string[]) {
const prisma = await getPrisma()
return ( return (
await prisma.group.findMany({ await prisma.group.findMany({
where: { id: { in: groupIds } }, where: { id: { in: groupIds } },
@@ -114,6 +129,7 @@ export async function updateExpense(
groupId: string, groupId: string,
expenseId: string, expenseId: string,
expenseFormValues: ExpenseFormValues, expenseFormValues: ExpenseFormValues,
participantId?: string,
) { ) {
const group = await getGroup(groupId) const group = await getGroup(groupId)
if (!group) throw new Error(`Invalid group ID: ${groupId}`) if (!group) throw new Error(`Invalid group ID: ${groupId}`)
@@ -129,7 +145,12 @@ export async function updateExpense(
throw new Error(`Invalid participant ID: ${participant}`) throw new Error(`Invalid participant ID: ${participant}`)
} }
const prisma = await getPrisma() await logActivity(groupId, ActivityType.UPDATE_EXPENSE, {
participantId,
expenseId,
data: expenseFormValues.title,
})
return prisma.expense.update({ return prisma.expense.update({
where: { id: expenseId }, where: { id: expenseId },
data: { data: {
@@ -194,11 +215,13 @@ export async function updateExpense(
export async function updateGroup( export async function updateGroup(
groupId: string, groupId: string,
groupFormValues: GroupFormValues, groupFormValues: GroupFormValues,
participantId?: string,
) { ) {
const existingGroup = await getGroup(groupId) const existingGroup = await getGroup(groupId)
if (!existingGroup) throw new Error('Invalid group ID') if (!existingGroup) throw new Error('Invalid group ID')
const prisma = await getPrisma() await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId })
return prisma.group.update({ return prisma.group.update({
where: { id: groupId }, where: { id: groupId },
data: { data: {
@@ -230,7 +253,6 @@ export async function updateGroup(
} }
export async function getGroup(groupId: string) { export async function getGroup(groupId: string) {
const prisma = await getPrisma()
return prisma.group.findUnique({ return prisma.group.findUnique({
where: { id: groupId }, where: { id: groupId },
include: { participants: true }, include: { participants: true },
@@ -238,27 +260,67 @@ export async function getGroup(groupId: string) {
} }
export async function getCategories() { export async function getCategories() {
const prisma = await getPrisma()
return prisma.category.findMany() return prisma.category.findMany()
} }
export async function getGroupExpenses(groupId: string) { export async function getGroupExpenses(
const prisma = await getPrisma() groupId: string,
options?: { offset: number; length: number },
) {
return prisma.expense.findMany({ return prisma.expense.findMany({
where: { groupId }, select: {
include: { amount: true,
paidFor: { include: { participant: true } },
paidBy: true,
category: true, category: true,
createdAt: true,
expenseDate: true,
id: true,
isReimbursement: true,
paidBy: { select: { id: true, name: true } },
paidFor: {
select: {
participant: { select: { id: true, name: true } },
shares: true,
},
},
splitMode: true,
title: true,
}, },
where: { groupId },
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }], orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
skip: options && options.offset,
take: options && options.length,
}) })
} }
export async function getGroupExpenseCount(groupId: string) {
return prisma.expense.count({ where: { groupId } })
}
export async function getExpense(groupId: string, expenseId: string) { export async function getExpense(groupId: string, expenseId: string) {
const prisma = await getPrisma()
return prisma.expense.findUnique({ return prisma.expense.findUnique({
where: { id: expenseId }, where: { id: expenseId },
include: { paidBy: true, paidFor: true, category: true, documents: true }, include: { paidBy: true, paidFor: true, category: true, documents: true },
}) })
} }
export async function getActivities(groupId: string) {
return prisma.activity.findMany({
where: { groupId },
orderBy: [{ time: 'desc' }],
})
}
export async function logActivity(
groupId: string,
activityType: ActivityType,
extra?: { participantId?: string; expenseId?: string; data?: string },
) {
return prisma.activity.create({
data: {
id: randomId(),
groupId,
activityType,
...extra,
},
})
}

View File

@@ -19,7 +19,7 @@ export function getBalances(
const balances: Balances = {} const balances: Balances = {}
for (const expense of expenses) { for (const expense of expenses) {
const paidBy = expense.paidById const paidBy = expense.paidBy.id
const paidFors = expense.paidFor const paidFors = expense.paidFor
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 } if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
@@ -31,8 +31,8 @@ export function getBalances(
) )
let remaining = expense.amount let remaining = expense.amount
paidFors.forEach((paidFor, index) => { paidFors.forEach((paidFor, index) => {
if (!balances[paidFor.participantId]) if (!balances[paidFor.participant.id])
balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 } balances[paidFor.participant.id] = { paid: 0, paidFor: 0, total: 0 }
const isLast = index === paidFors.length - 1 const isLast = index === paidFors.length - 1
@@ -47,7 +47,7 @@ export function getBalances(
? remaining ? remaining
: (expense.amount * shares) / totalShares : (expense.amount * shares) / totalShares
remaining -= dividedAmount remaining -= dividedAmount
balances[paidFor.participantId].paidFor += dividedAmount balances[paidFor.participant.id].paidFor += dividedAmount
}) })
} }

View File

@@ -1,20 +1,21 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
let prisma: PrismaClient declare const global: Global & { prisma?: PrismaClient }
export async function getPrisma() { export let p: PrismaClient = undefined as any as PrismaClient
if (typeof window === 'undefined') {
// await delay(1000) // await delay(1000)
if (!prisma) { if (process.env['NODE_ENV'] === 'production') {
if (process.env.NODE_ENV === 'production') { p = new PrismaClient()
prisma = new PrismaClient() } else {
} else { if (!global.prisma) {
if (!(global as any).prisma) { global.prisma = new PrismaClient({
;(global as any).prisma = new PrismaClient({ // log: [{ emit: 'stdout', level: 'query' }],
// log: [{ emit: 'stdout', level: 'query' }], })
})
}
prisma = (global as any).prisma
} }
p = global.prisma
} }
return prisma
} }
export const prisma = p

View File

@@ -62,7 +62,7 @@ export const expenseFormSchema = z
], ],
{ required_error: 'You must enter an amount.' }, { required_error: 'You must enter an amount.' },
) )
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.') .refine((amount) => amount != 1, 'The amount must not be zero.')
.refine( .refine(
(amount) => amount <= 10_000_000_00, (amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.', 'The amount must be lower than 10,000,000.',

View File

@@ -34,7 +34,7 @@ export function getTotalActiveUserShare(
const paidFors = expense.paidFor const paidFors = expense.paidFor
const userPaidFor = paidFors.find( const userPaidFor = paidFors.find(
(paidFor) => paidFor.participantId === activeUserId, (paidFor) => paidFor.participant.id === activeUserId,
) )
if (!userPaidFor) { if (!userPaidFor) {

View File

@@ -10,9 +10,15 @@ export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
export function formatExpenseDate(date: Date) { export type DateTimeStyle = NonNullable<
return date.toLocaleDateString('en-US', { ConstructorParameters<typeof Intl.DateTimeFormat>[1]
dateStyle: 'medium', >['dateStyle']
export function formatDate(
date: Date,
options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {},
) {
return date.toLocaleString('en-GB', {
...options,
timeZone: 'UTC', timeZone: 'UTC',
}) })
} }

View File

@@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { randomId } from '@/lib/api' import { randomId } from '@/lib/api'
import { getPrisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { Client } from 'pg' import { Client } from 'pg'
@@ -8,8 +8,6 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
async function main() { async function main() {
withClient(async (client) => { withClient(async (client) => {
const prisma = await getPrisma()
// console.log('Deleting all groups…') // console.log('Deleting all groups…')
// await prisma.group.deleteMany({}) // await prisma.group.deleteMany({})