mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-07 12:56:12 +01:00
Use tRPC in other group pages (#249)
* Use tRPC in group edition + group layout * Use tRPC in group modals * Use tRPC in group stats * Use tRPC in group activity
This commit is contained in:
committed by
GitHub
parent
66e15e419e
commit
210c12b7ef
@@ -1,18 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { getGroupExpenses } from '@/lib/api'
|
|
||||||
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
|
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
|
||||||
import { Activity, ActivityType, Participant } from '@prisma/client'
|
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||||
|
import { ActivityType, Participant } from '@prisma/client'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export type Activity =
|
||||||
|
AppRouterOutput['groups']['activities']['list']['activities'][number]
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
groupId: string
|
groupId: string
|
||||||
activity: Activity
|
activity: Activity
|
||||||
participant?: Participant
|
participant?: Participant
|
||||||
expense?: Awaited<ReturnType<typeof getGroupExpenses>>[number]
|
|
||||||
dateStyle: DateTimeStyle
|
dateStyle: DateTimeStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +46,12 @@ export function ActivityItem({
|
|||||||
groupId,
|
groupId,
|
||||||
activity,
|
activity,
|
||||||
participant,
|
participant,
|
||||||
expense,
|
|
||||||
dateStyle,
|
dateStyle,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
|
|
||||||
const expenseExists = expense !== undefined
|
const expenseExists = activity.expense !== undefined
|
||||||
const summary = useSummary(activity, participant?.name)
|
const summary = useSummary(activity, participant?.name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
|
'use client'
|
||||||
import { getGroupExpenses } from '@/lib/api'
|
import {
|
||||||
import { Activity, Participant } from '@prisma/client'
|
Activity,
|
||||||
|
ActivityItem,
|
||||||
|
} from '@/app/groups/[groupId]/activity/activity-item'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
import dayjs, { type Dayjs } from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { forwardRef, useEffect } from 'react'
|
||||||
|
import { useInView } from 'react-intersection-observer'
|
||||||
|
|
||||||
type Props = {
|
const PAGE_SIZE = 20
|
||||||
groupId: string
|
|
||||||
participants: Participant[]
|
|
||||||
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
|
|
||||||
activities: Activity[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATE_GROUPS = {
|
const DATE_GROUPS = {
|
||||||
TODAY: 'today',
|
TODAY: 'today',
|
||||||
@@ -48,23 +49,64 @@ function getDateGroup(date: Dayjs, today: Dayjs) {
|
|||||||
function getGroupedActivitiesByDate(activities: Activity[]) {
|
function getGroupedActivitiesByDate(activities: Activity[]) {
|
||||||
const today = dayjs()
|
const today = dayjs()
|
||||||
return activities.reduce(
|
return activities.reduce(
|
||||||
(result: { [key: string]: Activity[] }, activity: Activity) => {
|
(result, activity) => {
|
||||||
const activityGroup = getDateGroup(dayjs(activity.time), today)
|
const activityGroup = getDateGroup(dayjs(activity.time), today)
|
||||||
result[activityGroup] = result[activityGroup] ?? []
|
result[activityGroup] = result[activityGroup] ?? []
|
||||||
result[activityGroup].push(activity)
|
result[activityGroup].push(activity)
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
{},
|
{} as {
|
||||||
|
[key: string]: Activity[]
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityList({
|
const ActivitiesLoading = forwardRef<HTMLDivElement>((_, ref) => {
|
||||||
groupId,
|
return (
|
||||||
participants,
|
<div ref={ref} className="flex flex-col gap-4">
|
||||||
expenses,
|
<Skeleton className="mt-2 h-3 w-24" />
|
||||||
activities,
|
{Array(5)
|
||||||
}: Props) {
|
.fill(undefined)
|
||||||
|
.map((_, index) => (
|
||||||
|
<div key={index} className="flex gap-2 p-2">
|
||||||
|
<div className="flex-0">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ActivitiesLoading.displayName = 'ActivitiesLoading'
|
||||||
|
|
||||||
|
export function ActivityList({ groupId }: { groupId: string }) {
|
||||||
const t = useTranslations('Activity')
|
const t = useTranslations('Activity')
|
||||||
|
|
||||||
|
const { data: groupData, isLoading: groupIsLoading } =
|
||||||
|
trpc.groups.get.useQuery({ groupId })
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: activitiesData,
|
||||||
|
isLoading,
|
||||||
|
fetchNextPage,
|
||||||
|
} = trpc.groups.activities.list.useInfiniteQuery(
|
||||||
|
{ groupId, limit: PAGE_SIZE },
|
||||||
|
{ getNextPageParam: ({ nextCursor }) => nextCursor },
|
||||||
|
)
|
||||||
|
const { ref: loadingRef, inView } = useInView()
|
||||||
|
|
||||||
|
const activities = activitiesData?.pages.flatMap((page) => page.activities)
|
||||||
|
const hasMore = activitiesData?.pages.at(-1)?.hasMore ?? false
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inView && hasMore && !isLoading) fetchNextPage()
|
||||||
|
}, [fetchNextPage, hasMore, inView, isLoading])
|
||||||
|
|
||||||
|
if (isLoading || !activities || !groupData) return <ActivitiesLoading />
|
||||||
|
|
||||||
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
|
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
|
||||||
|
|
||||||
return activities.length > 0 ? (
|
return activities.length > 0 ? (
|
||||||
@@ -86,27 +128,29 @@ export function ActivityList({
|
|||||||
>
|
>
|
||||||
{t(`Groups.${dateGroup}`)}
|
{t(`Groups.${dateGroup}`)}
|
||||||
</div>
|
</div>
|
||||||
{groupActivities.map((activity: Activity) => {
|
{groupActivities.map((activity) => {
|
||||||
const participant =
|
const participant =
|
||||||
activity.participantId !== null
|
activity.participantId !== null
|
||||||
? participants.find((p) => p.id === activity.participantId)
|
? groupData.group.participants.find(
|
||||||
: undefined
|
(p) => p.id === activity.participantId,
|
||||||
const expense =
|
)
|
||||||
activity.expenseId !== null
|
|
||||||
? expenses.find((e) => e.id === activity.expenseId)
|
|
||||||
: undefined
|
: undefined
|
||||||
return (
|
return (
|
||||||
<ActivityItem
|
<ActivityItem
|
||||||
key={activity.id}
|
key={activity.id}
|
||||||
{...{ groupId, activity, participant, expense, dateStyle }}
|
groupId={groupId}
|
||||||
|
activity={activity}
|
||||||
|
participant={participant}
|
||||||
|
dateStyle={dateStyle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{hasMore && <ActivitiesLoading ref={loadingRef} />}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="px-6 text-sm py-6">{t('noActivity')}</p>
|
<p className="text-sm py-6">{t('noActivity')}</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/app/groups/[groupId]/activity/page.client.tsx
Normal file
32
src/app/groups/[groupId]/activity/page.client.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Activity',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityPageClient({ groupId }: { groupId: string }) {
|
||||||
|
const t = useTranslations('Activity')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
|
<CardDescription>{t('description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col space-y-4">
|
||||||
|
<ActivityList groupId={groupId} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
import { cached } from '@/app/cached-functions'
|
import { ActivityPageClient } from '@/app/groups/[groupId]/activity/page.client'
|
||||||
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 { Metadata } from 'next'
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Activity',
|
title: 'Activity',
|
||||||
@@ -21,31 +10,5 @@ export default async function ActivityPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { groupId: string }
|
params: { groupId: string }
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations('Activity')
|
return <ActivityPageClient groupId={groupId} />
|
||||||
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>{t('title')}</CardTitle>
|
|
||||||
<CardDescription>{t('description')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col space-y-4">
|
|
||||||
<ActivityList
|
|
||||||
{...{
|
|
||||||
groupId,
|
|
||||||
participants: group.participants,
|
|
||||||
expenses,
|
|
||||||
activities,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,23 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { getGroup } from '@/lib/api'
|
|
||||||
import { trpc } from '@/trpc/client'
|
import { trpc } from '@/trpc/client'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { Fragment, useEffect } from 'react'
|
import { Fragment, useEffect } from 'react'
|
||||||
|
|
||||||
export default function BalancesAndReimbursements({
|
export default function BalancesAndReimbursements({
|
||||||
group,
|
groupId,
|
||||||
}: {
|
}: {
|
||||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
groupId: string
|
||||||
}) {
|
}) {
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
const { data: groupData, isLoading: groupIsLoading } =
|
||||||
|
trpc.groups.get.useQuery({ groupId })
|
||||||
|
const { data: balancesData, isLoading: balancesAreLoading } =
|
||||||
|
trpc.groups.balances.list.useQuery({
|
||||||
|
groupId,
|
||||||
|
})
|
||||||
|
const t = useTranslations('Balances')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Until we use tRPC more widely and can invalidate the cache on expense
|
// Until we use tRPC more widely and can invalidate the cache on expense
|
||||||
@@ -28,11 +34,8 @@ export default function BalancesAndReimbursements({
|
|||||||
utils.groups.balances.invalidate()
|
utils.groups.balances.invalidate()
|
||||||
}, [utils])
|
}, [utils])
|
||||||
|
|
||||||
const t = useTranslations('Balances')
|
const isLoading =
|
||||||
|
balancesAreLoading || !balancesData || groupIsLoading || !groupData?.group
|
||||||
const { data, isLoading } = trpc.groups.balances.list.useQuery({
|
|
||||||
groupId: group.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -42,13 +45,15 @@ export default function BalancesAndReimbursements({
|
|||||||
<CardDescription>{t('description')}</CardDescription>
|
<CardDescription>{t('description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading || !data ? (
|
{isLoading ? (
|
||||||
<BalancesLoading participantCount={group.participants.length} />
|
<BalancesLoading
|
||||||
|
participantCount={groupData?.group.participants.length}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BalancesList
|
<BalancesList
|
||||||
balances={data.balances}
|
balances={balancesData.balances}
|
||||||
participants={group.participants}
|
participants={groupData.group.participants}
|
||||||
currency={group.currency}
|
currency={groupData.group.currency}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -59,16 +64,16 @@ export default function BalancesAndReimbursements({
|
|||||||
<CardDescription>{t('Reimbursements.description')}</CardDescription>
|
<CardDescription>{t('Reimbursements.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading || !data ? (
|
{isLoading ? (
|
||||||
<ReimbursementsLoading
|
<ReimbursementsLoading
|
||||||
participantCount={group.participants.length}
|
participantCount={groupData?.group.participants.length}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ReimbursementList
|
<ReimbursementList
|
||||||
reimbursements={data.reimbursements}
|
reimbursements={balancesData.reimbursements}
|
||||||
participants={group.participants}
|
participants={groupData.group.participants}
|
||||||
currency={group.currency}
|
currency={groupData.group.currency}
|
||||||
groupId={group.id}
|
groupId={groupData.group.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -78,9 +83,9 @@ export default function BalancesAndReimbursements({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ReimbursementsLoading = ({
|
const ReimbursementsLoading = ({
|
||||||
participantCount,
|
participantCount = 3,
|
||||||
}: {
|
}: {
|
||||||
participantCount: number
|
participantCount?: number
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -100,9 +105,9 @@ const ReimbursementsLoading = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BalancesLoading = ({
|
const BalancesLoading = ({
|
||||||
participantCount,
|
participantCount = 3,
|
||||||
}: {
|
}: {
|
||||||
participantCount: number
|
participantCount?: number
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 py-1 gap-y-2">
|
<div className="grid grid-cols-2 py-1 gap-y-2">
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ export default async function GroupPage({
|
|||||||
const group = await cached.getGroup(groupId)
|
const group = await cached.getGroup(groupId)
|
||||||
if (!group) notFound()
|
if (!group) notFound()
|
||||||
|
|
||||||
return <BalancesAndReimbursements group={group} />
|
return <BalancesAndReimbursements groupId={groupId} />
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/app/groups/[groupId]/edit/edit-group.tsx
Normal file
30
src/app/groups/[groupId]/edit/edit-group.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { GroupForm } from '@/components/group-form'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
|
|
||||||
|
export const EditGroup = ({ groupId }: { groupId: string }) => {
|
||||||
|
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||||
|
const { mutateAsync, isPending } = trpc.groups.update.useMutation()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
// async function updateGroupAction(values: unknown, participantId?: string) {
|
||||||
|
// 'use server'
|
||||||
|
// const groupFormValues = groupFormSchema.parse(values)
|
||||||
|
// const group = await updateGroup(groupId, groupFormValues, participantId)
|
||||||
|
// redirect(`/groups/${group.id}`)
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (isLoading) return <></>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GroupForm
|
||||||
|
group={data?.group}
|
||||||
|
onSubmit={async (groupFormValues, participantId) => {
|
||||||
|
await mutateAsync({ groupId, participantId, groupFormValues })
|
||||||
|
await utils.groups.invalidate()
|
||||||
|
}}
|
||||||
|
protectedParticipantIds={data?.participantsWithExpenses}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import { cached } from '@/app/cached-functions'
|
import { EditGroup } from '@/app/groups/[groupId]/edit/edit-group'
|
||||||
import { GroupForm } from '@/components/group-form'
|
|
||||||
import { getGroupExpensesParticipants, updateGroup } from '@/lib/api'
|
|
||||||
import { groupFormSchema } from '@/lib/schemas'
|
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -14,22 +10,5 @@ export default async function EditGroupPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { groupId: string }
|
params: { groupId: string }
|
||||||
}) {
|
}) {
|
||||||
const group = await cached.getGroup(groupId)
|
return <EditGroup groupId={groupId} />
|
||||||
if (!group) notFound()
|
|
||||||
|
|
||||||
async function updateGroupAction(values: unknown, participantId?: string) {
|
|
||||||
'use server'
|
|
||||||
const groupFormValues = groupFormSchema.parse(values)
|
|
||||||
const group = await updateGroup(groupId, groupFormValues, participantId)
|
|
||||||
redirect(`/groups/${group.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const protectedParticipantIds = await getGroupExpensesParticipants(groupId)
|
|
||||||
return (
|
|
||||||
<GroupForm
|
|
||||||
group={group}
|
|
||||||
onSubmit={updateGroupAction}
|
|
||||||
protectedParticipantIds={protectedParticipantIds}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,22 +18,24 @@ import {
|
|||||||
} from '@/components/ui/drawer'
|
} from '@/components/ui/drawer'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import { getGroup } from '@/lib/api'
|
|
||||||
import { useMediaQuery } from '@/lib/hooks'
|
import { useMediaQuery } from '@/lib/hooks'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
|
import { AppRouterOutput } from '@/trpc/routers/_app'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { ComponentProps, useEffect, useState } from 'react'
|
import { ComponentProps, useEffect, useState } from 'react'
|
||||||
|
|
||||||
type Props = {
|
export function ActiveUserModal({ groupId }: { groupId: string }) {
|
||||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActiveUserModal({ group }: Props) {
|
|
||||||
const t = useTranslations('Expenses.ActiveUserModal')
|
const t = useTranslations('Expenses.ActiveUserModal')
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||||
|
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
|
||||||
|
|
||||||
|
const group = groupData?.group
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!group) return
|
||||||
|
|
||||||
const tempUser = localStorage.getItem(`newGroup-activeUser`)
|
const tempUser = localStorage.getItem(`newGroup-activeUser`)
|
||||||
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
||||||
if (!tempUser && !activeUser) {
|
if (!tempUser && !activeUser) {
|
||||||
@@ -42,6 +44,8 @@ export function ActiveUserModal({ group }: Props) {
|
|||||||
}, [group])
|
}, [group])
|
||||||
|
|
||||||
function updateOpen(open: boolean) {
|
function updateOpen(open: boolean) {
|
||||||
|
if (!group) return
|
||||||
|
|
||||||
if (!open && !localStorage.getItem(`${group.id}-activeUser`)) {
|
if (!open && !localStorage.getItem(`${group.id}-activeUser`)) {
|
||||||
localStorage.setItem(`${group.id}-activeUser`, 'None')
|
localStorage.setItem(`${group.id}-activeUser`, 'None')
|
||||||
}
|
}
|
||||||
@@ -93,7 +97,10 @@ function ActiveUserForm({
|
|||||||
group,
|
group,
|
||||||
close,
|
close,
|
||||||
className,
|
className,
|
||||||
}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
|
}: ComponentProps<'form'> & {
|
||||||
|
group?: AppRouterOutput['groups']['get']['group']
|
||||||
|
close: () => void
|
||||||
|
}) {
|
||||||
const t = useTranslations('Expenses.ActiveUserModal')
|
const t = useTranslations('Expenses.ActiveUserModal')
|
||||||
const [selected, setSelected] = useState('None')
|
const [selected, setSelected] = useState('None')
|
||||||
|
|
||||||
@@ -101,6 +108,8 @@ function ActiveUserForm({
|
|||||||
<form
|
<form
|
||||||
className={cn('grid items-start gap-4', className)}
|
className={cn('grid items-start gap-4', className)}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
|
if (!group) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
localStorage.setItem(`${group.id}-activeUser`, selected)
|
localStorage.setItem(`${group.id}-activeUser`, selected)
|
||||||
close()
|
close()
|
||||||
@@ -114,7 +123,7 @@ function ActiveUserForm({
|
|||||||
{t('nobody')}
|
{t('nobody')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{group.participants.map((participant) => (
|
{group?.participants.map((participant) => (
|
||||||
<div key={participant.id} className="flex items-center space-x-2">
|
<div key={participant.id} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={participant.id} id={participant.id} />
|
<RadioGroupItem value={participant.id} id={participant.id} />
|
||||||
<Label htmlFor={participant.id} className="flex-1">
|
<Label htmlFor={participant.id} className="flex-1">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ 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, formatDate, formatFileSize } from '@/lib/utils'
|
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
|
||||||
import { Category } from '@prisma/client'
|
import { trpc } from '@/trpc/client'
|
||||||
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
|
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import { getImageData, usePresignedUpload } from 'next-s3-upload'
|
import { getImageData, usePresignedUpload } from 'next-s3-upload'
|
||||||
@@ -35,19 +35,52 @@ import Image from 'next/image'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { PropsWithChildren, ReactNode, useState } from 'react'
|
import { PropsWithChildren, ReactNode, useState } from 'react'
|
||||||
|
|
||||||
type Props = {
|
|
||||||
groupId: string
|
|
||||||
groupCurrency: string
|
|
||||||
categories: Category[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 ** 2
|
const MAX_FILE_SIZE = 5 * 1024 ** 2
|
||||||
|
|
||||||
export function CreateFromReceiptButton({
|
export function CreateFromReceiptButton({ groupId }: { groupId: string }) {
|
||||||
groupId,
|
return <CreateFromReceiptButton_ groupId={groupId} />
|
||||||
groupCurrency,
|
}
|
||||||
categories,
|
|
||||||
}: Props) {
|
function CreateFromReceiptButton_({ groupId }: { groupId: string }) {
|
||||||
|
const t = useTranslations('CreateFromReceipt')
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||||
|
|
||||||
|
const DialogOrDrawer = isDesktop
|
||||||
|
? CreateFromReceiptDialog
|
||||||
|
: CreateFromReceiptDrawer
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogOrDrawer
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
title={t('Dialog.triggerTitle')}
|
||||||
|
>
|
||||||
|
<Receipt className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<span>{t('Dialog.title')}</span>
|
||||||
|
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
|
||||||
|
Beta
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={<>{t('Dialog.description')}</>}
|
||||||
|
>
|
||||||
|
<ReceiptDialogContent groupId={groupId} />
|
||||||
|
</DialogOrDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiptDialogContent({ groupId }: { groupId: string }) {
|
||||||
|
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
|
||||||
|
const { data: categoriesData } = trpc.categories.list.useQuery()
|
||||||
|
const group = groupData?.group
|
||||||
|
const categories = categoriesData?.categories
|
||||||
|
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const t = useTranslations('CreateFromReceipt')
|
const t = useTranslations('CreateFromReceipt')
|
||||||
const [pending, setPending] = useState(false)
|
const [pending, setPending] = useState(false)
|
||||||
@@ -58,7 +91,6 @@ export function CreateFromReceiptButton({
|
|||||||
| null
|
| null
|
||||||
| (ReceiptExtractedInfo & { url: string; width?: number; height?: number })
|
| (ReceiptExtractedInfo & { url: string; width?: number; height?: number })
|
||||||
>(null)
|
>(null)
|
||||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
|
||||||
|
|
||||||
const handleFileChange = async (file: File) => {
|
const handleFileChange = async (file: File) => {
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
@@ -107,160 +139,130 @@ export function CreateFromReceiptButton({
|
|||||||
|
|
||||||
const receiptInfoCategory =
|
const receiptInfoCategory =
|
||||||
(receiptInfo?.categoryId &&
|
(receiptInfo?.categoryId &&
|
||||||
categories.find((c) => String(c.id) === receiptInfo.categoryId)) ||
|
categories?.find((c) => String(c.id) === receiptInfo.categoryId)) ||
|
||||||
null
|
null
|
||||||
|
|
||||||
const DialogOrDrawer = isDesktop
|
|
||||||
? CreateFromReceiptDialog
|
|
||||||
: CreateFromReceiptDrawer
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogOrDrawer
|
<div className="prose prose-sm dark:prose-invert">
|
||||||
trigger={
|
<p>{t('Dialog.body')}</p>
|
||||||
<Button
|
<div>
|
||||||
size="icon"
|
<FileInput onChange={handleFileChange} accept="image/jpeg,image/png" />
|
||||||
variant="secondary"
|
<div className="grid gap-x-4 gap-y-2 grid-cols-3">
|
||||||
title={t('Dialog.triggerTitle')}
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
<Receipt className="w-4 h-4" />
|
className="row-span-3 w-full h-full relative"
|
||||||
</Button>
|
title="Create expense from receipt"
|
||||||
}
|
onClick={openFileDialog}
|
||||||
title={
|
disabled={pending}
|
||||||
<>
|
>
|
||||||
<span>{t('Dialog.title')}</span>
|
{pending ? (
|
||||||
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
Beta
|
) : receiptInfo ? (
|
||||||
</Badge>
|
<div className="absolute top-2 left-2 bottom-2 right-2">
|
||||||
</>
|
<Image
|
||||||
}
|
src={receiptInfo.url}
|
||||||
description={<>{t('Dialog.description')}</>}
|
width={receiptInfo.width}
|
||||||
>
|
height={receiptInfo.height}
|
||||||
<div className="prose prose-sm dark:prose-invert">
|
className="w-full h-full m-0 object-contain drop-shadow-lg"
|
||||||
<p>{t('Dialog.body')}</p>
|
alt="Scanned receipt"
|
||||||
<div>
|
/>
|
||||||
<FileInput
|
</div>
|
||||||
onChange={handleFileChange}
|
) : (
|
||||||
accept="image/jpeg,image/png"
|
<span className="text-xs sm:text-sm text-muted-foreground">
|
||||||
/>
|
{t('Dialog.selectImage')}
|
||||||
<div className="grid gap-x-4 gap-y-2 grid-cols-3">
|
</span>
|
||||||
<Button
|
)}
|
||||||
variant="secondary"
|
</Button>
|
||||||
className="row-span-3 w-full h-full relative"
|
<div className="col-span-2">
|
||||||
title="Create expense from receipt"
|
<strong>{t('Dialog.titleLabel')}</strong>
|
||||||
onClick={openFileDialog}
|
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
|
||||||
disabled={pending}
|
</div>
|
||||||
>
|
<div className="col-span-2">
|
||||||
{pending ? (
|
<strong>{t('Dialog.categoryLabel')}</strong>
|
||||||
<Loader2 className="w-8 h-8 animate-spin" />
|
<div>
|
||||||
) : receiptInfo ? (
|
{receiptInfo ? (
|
||||||
<div className="absolute top-2 left-2 bottom-2 right-2">
|
receiptInfoCategory ? (
|
||||||
<Image
|
<div className="flex items-center">
|
||||||
src={receiptInfo.url}
|
<CategoryIcon
|
||||||
width={receiptInfo.width}
|
category={receiptInfoCategory}
|
||||||
height={receiptInfo.height}
|
className="inline w-4 h-4 mr-2"
|
||||||
className="w-full h-full m-0 object-contain drop-shadow-lg"
|
/>
|
||||||
alt="Scanned receipt"
|
<span className="mr-1">{receiptInfoCategory.grouping}</span>
|
||||||
/>
|
<ChevronRight className="inline w-3 h-3 mr-1" />
|
||||||
</div>
|
<span>{receiptInfoCategory.name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Unknown />
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs sm:text-sm text-muted-foreground">
|
'' || '…'
|
||||||
{t('Dialog.selectImage')}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<strong>{t('Dialog.titleLabel')}</strong>
|
|
||||||
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<strong>{t('Dialog.categoryLabel')}</strong>
|
|
||||||
<div>
|
|
||||||
{receiptInfo ? (
|
|
||||||
receiptInfoCategory ? (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<CategoryIcon
|
|
||||||
category={receiptInfoCategory}
|
|
||||||
className="inline w-4 h-4 mr-2"
|
|
||||||
/>
|
|
||||||
<span className="mr-1">
|
|
||||||
{receiptInfoCategory.grouping}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="inline w-3 h-3 mr-1" />
|
|
||||||
<span>{receiptInfoCategory.name}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Unknown />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
'' || '…'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{t('Dialog.amountLabel')}</strong>
|
||||||
<div>
|
<div>
|
||||||
<strong>{t('Dialog.amountLabel')}</strong>
|
{receiptInfo && group ? (
|
||||||
<div>
|
receiptInfo.amount ? (
|
||||||
{receiptInfo ? (
|
<>
|
||||||
receiptInfo.amount ? (
|
{formatCurrency(
|
||||||
<>
|
group.currency,
|
||||||
{formatCurrency(
|
receiptInfo.amount,
|
||||||
groupCurrency,
|
|
||||||
receiptInfo.amount,
|
|
||||||
locale,
|
|
||||||
true,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Unknown />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
'…'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{t('Dialog.dateLabel')}</strong>
|
|
||||||
<div>
|
|
||||||
{receiptInfo ? (
|
|
||||||
receiptInfo.date ? (
|
|
||||||
formatDate(
|
|
||||||
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
|
|
||||||
locale,
|
locale,
|
||||||
{ dateStyle: 'medium' },
|
true,
|
||||||
)
|
)}
|
||||||
) : (
|
</>
|
||||||
<Unknown />
|
) : (
|
||||||
|
<Unknown />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
'…'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{t('Dialog.dateLabel')}</strong>
|
||||||
|
<div>
|
||||||
|
{receiptInfo ? (
|
||||||
|
receiptInfo.date ? (
|
||||||
|
formatDate(
|
||||||
|
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
|
||||||
|
locale,
|
||||||
|
{ dateStyle: 'medium' },
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
'…'
|
<Unknown />
|
||||||
)}
|
)
|
||||||
</div>
|
) : (
|
||||||
|
'…'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>{t('Dialog.editNext')}</p>
|
|
||||||
<div className="text-center">
|
|
||||||
<Button
|
|
||||||
disabled={pending || !receiptInfo}
|
|
||||||
onClick={() => {
|
|
||||||
if (!receiptInfo) return
|
|
||||||
router.push(
|
|
||||||
`/groups/${groupId}/expenses/create?amount=${
|
|
||||||
receiptInfo.amount
|
|
||||||
}&categoryId=${receiptInfo.categoryId}&date=${
|
|
||||||
receiptInfo.date
|
|
||||||
}&title=${encodeURIComponent(
|
|
||||||
receiptInfo.title ?? '',
|
|
||||||
)}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
|
|
||||||
receiptInfo.width
|
|
||||||
}&imageHeight=${receiptInfo.height}`,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('Dialog.continue')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogOrDrawer>
|
<p>{t('Dialog.editNext')}</p>
|
||||||
|
<div className="text-center">
|
||||||
|
<Button
|
||||||
|
disabled={pending || !receiptInfo}
|
||||||
|
onClick={() => {
|
||||||
|
if (!receiptInfo || !group) return
|
||||||
|
router.push(
|
||||||
|
`/groups/${group.id}/expenses/create?amount=${
|
||||||
|
receiptInfo.amount
|
||||||
|
}&categoryId=${receiptInfo.categoryId}&date=${
|
||||||
|
receiptInfo.date
|
||||||
|
}&title=${encodeURIComponent(
|
||||||
|
receiptInfo.title ?? '',
|
||||||
|
)}&imageUrl=${encodeURIComponent(receiptInfo.url)}&imageWidth=${
|
||||||
|
receiptInfo.width
|
||||||
|
}&imageHeight=${receiptInfo.height}`,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Dialog.continue')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { SearchBar } from '@/components/ui/search-bar'
|
import { SearchBar } from '@/components/ui/search-bar'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { trpc } from '@/trpc/client'
|
import { trpc } from '@/trpc/client'
|
||||||
import { Participant } from '@prisma/client'
|
|
||||||
import dayjs, { type Dayjs } from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@@ -13,18 +12,12 @@ import { forwardRef, useEffect, useMemo, useState } from 'react'
|
|||||||
import { useInView } from 'react-intersection-observer'
|
import { useInView } from 'react-intersection-observer'
|
||||||
import { useDebounce } from 'use-debounce'
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
const PAGE_SIZE = 200
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
type ExpensesType = NonNullable<
|
type ExpensesType = NonNullable<
|
||||||
Awaited<ReturnType<typeof getGroupExpensesAction>>
|
Awaited<ReturnType<typeof getGroupExpensesAction>>
|
||||||
>
|
>
|
||||||
|
|
||||||
type Props = {
|
|
||||||
participants: Participant[]
|
|
||||||
currency: string
|
|
||||||
groupId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXPENSE_GROUPS = {
|
const EXPENSE_GROUPS = {
|
||||||
UPCOMING: 'upcoming',
|
UPCOMING: 'upcoming',
|
||||||
THIS_WEEK: 'thisWeek',
|
THIS_WEEK: 'thisWeek',
|
||||||
@@ -63,11 +56,16 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpenseList({ currency, participants, groupId }: Props) {
|
export function ExpenseList({ groupId }: { groupId: string }) {
|
||||||
|
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const [debouncedSearchText] = useDebounce(searchText, 300)
|
const [debouncedSearchText] = useDebounce(searchText, 300)
|
||||||
|
|
||||||
|
const participants = groupData?.group.participants
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!participants) return
|
||||||
|
|
||||||
const activeUser = localStorage.getItem('newGroup-activeUser')
|
const activeUser = localStorage.getItem('newGroup-activeUser')
|
||||||
const newUser = localStorage.getItem(`${groupId}-newUser`)
|
const newUser = localStorage.getItem(`${groupId}-newUser`)
|
||||||
if (activeUser || newUser) {
|
if (activeUser || newUser) {
|
||||||
@@ -91,7 +89,6 @@ export function ExpenseList({ currency, participants, groupId }: Props) {
|
|||||||
<SearchBar onValueChange={(value) => setSearchText(value)} />
|
<SearchBar onValueChange={(value) => setSearchText(value)} />
|
||||||
<ExpenseListForSearch
|
<ExpenseListForSearch
|
||||||
groupId={groupId}
|
groupId={groupId}
|
||||||
currency={currency}
|
|
||||||
searchText={debouncedSearchText}
|
searchText={debouncedSearchText}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -99,11 +96,9 @@ export function ExpenseList({ currency, participants, groupId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ExpenseListForSearch = ({
|
const ExpenseListForSearch = ({
|
||||||
currency,
|
|
||||||
groupId,
|
groupId,
|
||||||
searchText,
|
searchText,
|
||||||
}: {
|
}: {
|
||||||
currency: string
|
|
||||||
groupId: string
|
groupId: string
|
||||||
searchText: string
|
searchText: string
|
||||||
}) => {
|
}) => {
|
||||||
@@ -118,14 +113,23 @@ const ExpenseListForSearch = ({
|
|||||||
const t = useTranslations('Expenses')
|
const t = useTranslations('Expenses')
|
||||||
const { ref: loadingRef, inView } = useInView()
|
const { ref: loadingRef, inView } = useInView()
|
||||||
|
|
||||||
const { data, isLoading, isError, fetchNextPage } =
|
const {
|
||||||
trpc.groups.expenses.list.useInfiniteQuery(
|
data,
|
||||||
{ groupId, limit: PAGE_SIZE, filter: searchText },
|
isLoading: expensesAreLoading,
|
||||||
{ getNextPageParam: ({ nextCursor }) => nextCursor },
|
fetchNextPage,
|
||||||
)
|
} = trpc.groups.expenses.list.useInfiniteQuery(
|
||||||
|
{ groupId, limit: PAGE_SIZE, filter: searchText },
|
||||||
|
{ getNextPageParam: ({ nextCursor }) => nextCursor },
|
||||||
|
)
|
||||||
const expenses = data?.pages.flatMap((page) => page.expenses)
|
const expenses = data?.pages.flatMap((page) => page.expenses)
|
||||||
const hasMore = data?.pages.at(-1)?.hasMore ?? false
|
const hasMore = data?.pages.at(-1)?.hasMore ?? false
|
||||||
|
|
||||||
|
const { data: groupData, isLoading: groupIsLoading } =
|
||||||
|
trpc.groups.get.useQuery({ groupId })
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
expensesAreLoading || !expenses || groupIsLoading || !groupData
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inView && hasMore && !isLoading) fetchNextPage()
|
if (inView && hasMore && !isLoading) fetchNextPage()
|
||||||
}, [fetchNextPage, hasMore, inView, isLoading])
|
}, [fetchNextPage, hasMore, inView, isLoading])
|
||||||
@@ -137,7 +141,7 @@ const ExpenseListForSearch = ({
|
|||||||
|
|
||||||
if (isLoading) return <ExpensesLoading />
|
if (isLoading) return <ExpensesLoading />
|
||||||
|
|
||||||
if (!expenses || expenses?.length === 0)
|
if (expenses.length === 0)
|
||||||
return (
|
return (
|
||||||
<p className="px-6 text-sm py-6">
|
<p className="px-6 text-sm py-6">
|
||||||
{t('noExpenses')}{' '}
|
{t('noExpenses')}{' '}
|
||||||
@@ -168,7 +172,7 @@ const ExpenseListForSearch = ({
|
|||||||
<ExpenseCard
|
<ExpenseCard
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
expense={expense}
|
expense={expense}
|
||||||
currency={currency}
|
currency={groupData.group.currency}
|
||||||
groupId={groupId}
|
groupId={groupId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -183,7 +187,7 @@ const ExpenseListForSearch = ({
|
|||||||
const ExpensesLoading = forwardRef<HTMLDivElement>((_, ref) => {
|
const ExpensesLoading = forwardRef<HTMLDivElement>((_, ref) => {
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<Skeleton className="mx-4 sm:mx-6 mb-2 h-4 w-32 rounded-full" />
|
<Skeleton className="mx-4 sm:mx-6 mt-1 mb-2 h-3 w-32 rounded-full" />
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
|
|||||||
75
src/app/groups/[groupId]/expenses/page.client.tsx
Normal file
75
src/app/groups/[groupId]/expenses/page.client.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
|
||||||
|
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
|
||||||
|
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Download, Plus } from 'lucide-react'
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export const revalidate = 3600
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Expenses',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupExpensesPageClient({
|
||||||
|
groupId,
|
||||||
|
enableReceiptExtract,
|
||||||
|
}: {
|
||||||
|
groupId: string
|
||||||
|
enableReceiptExtract: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('Expenses')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<CardHeader className="flex-1 p-4 sm:p-6">
|
||||||
|
<CardTitle>{t('title')}</CardTitle>
|
||||||
|
<CardDescription>{t('description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
|
||||||
|
<Button variant="secondary" size="icon" asChild>
|
||||||
|
<Link
|
||||||
|
prefetch={false}
|
||||||
|
href={`/groups/${groupId}/expenses/export/json`}
|
||||||
|
target="_blank"
|
||||||
|
title={t('exportJson')}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{enableReceiptExtract && (
|
||||||
|
<CreateFromReceiptButton groupId={groupId} />
|
||||||
|
)}
|
||||||
|
<Button asChild size="icon">
|
||||||
|
<Link
|
||||||
|
href={`/groups/${groupId}/expenses/create`}
|
||||||
|
title={t('create')}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
|
||||||
|
<ExpenseList groupId={groupId} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ActiveUserModal groupId={groupId} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,6 @@
|
|||||||
import { cached } from '@/app/cached-functions'
|
import GroupExpensesPageClient from '@/app/groups/[groupId]/expenses/page.client'
|
||||||
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
|
|
||||||
import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button'
|
|
||||||
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { getCategories } from '@/lib/api'
|
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import { Download, Plus } from 'lucide-react'
|
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
|
|
||||||
export const revalidate = 3600
|
export const revalidate = 3600
|
||||||
|
|
||||||
@@ -29,59 +13,10 @@ export default async function GroupExpensesPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { groupId: string }
|
params: { groupId: string }
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations('Expenses')
|
|
||||||
const group = await cached.getGroup(groupId)
|
|
||||||
if (!group) notFound()
|
|
||||||
|
|
||||||
const categories = await getCategories()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<GroupExpensesPageClient
|
||||||
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
|
groupId={groupId}
|
||||||
<div className="flex flex-1">
|
enableReceiptExtract={env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT}
|
||||||
<CardHeader className="flex-1 p-4 sm:p-6">
|
/>
|
||||||
<CardTitle>{t('title')}</CardTitle>
|
|
||||||
<CardDescription>{t('description')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
|
|
||||||
<Button variant="secondary" size="icon" asChild>
|
|
||||||
<Link
|
|
||||||
prefetch={false}
|
|
||||||
href={`/groups/${groupId}/expenses/export/json`}
|
|
||||||
target="_blank"
|
|
||||||
title={t('exportJson')}
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
{env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT && (
|
|
||||||
<CreateFromReceiptButton
|
|
||||||
groupId={groupId}
|
|
||||||
groupCurrency={group.currency}
|
|
||||||
categories={categories}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button asChild size="icon">
|
|
||||||
<Link
|
|
||||||
href={`/groups/${groupId}/expenses/create`}
|
|
||||||
title={t('create')}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
|
|
||||||
<ExpenseList
|
|
||||||
groupId={group.id}
|
|
||||||
currency={group.currency}
|
|
||||||
participants={group.participants}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ActiveUserModal group={group} />
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/app/groups/[groupId]/group-header.tsx
Normal file
30
src/app/groups/[groupId]/group-header.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
|
||||||
|
import { ShareButton } from '@/app/groups/[groupId]/share-button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export const GroupHeader = ({ groupId }: { groupId: string }) => {
|
||||||
|
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-between gap-3">
|
||||||
|
<h1 className="font-bold text-2xl">
|
||||||
|
<Link href={`/groups/${groupId}`}>
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Skeleton className="mt-1.5 mb-1.5 h-5 w-32" />
|
||||||
|
) : (
|
||||||
|
<div className="flex">{data.group.name}</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-between">
|
||||||
|
<GroupTabs groupId={groupId} />
|
||||||
|
{data?.group && <ShareButton group={data.group} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import Link from 'next/link'
|
|||||||
|
|
||||||
export default function GroupInformation({ groupId }: { groupId: string }) {
|
export default function GroupInformation({ groupId }: { groupId: string }) {
|
||||||
const t = useTranslations('Information')
|
const t = useTranslations('Information')
|
||||||
const { data, isLoading } = trpc.groups.information.get.useQuery({ groupId })
|
const { data, isLoading } = trpc.groups.get.useQuery({ groupId })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -41,7 +41,7 @@ export default function GroupInformation({ groupId }: { groupId: string }) {
|
|||||||
<Skeleton className="h-3 w-1/2" />
|
<Skeleton className="h-3 w-1/2" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
data.information || (
|
data.group.information || (
|
||||||
<p className="text-muted-foreground text-sm">{t('empty')}</p>
|
<p className="text-muted-foreground text-sm">{t('empty')}</p>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { cached } from '@/app/cached-functions'
|
import { cached } from '@/app/cached-functions'
|
||||||
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
|
import { GroupHeader } from '@/app/groups/[groupId]/group-header'
|
||||||
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
|
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
|
||||||
import { ShareButton } from '@/app/groups/[groupId]/share-button'
|
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import Link from 'next/link'
|
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { PropsWithChildren, Suspense } from 'react'
|
import { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: {
|
params: {
|
||||||
@@ -35,18 +33,7 @@ export default async function GroupLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col justify-between gap-3">
|
<GroupHeader groupId={groupId} />
|
||||||
<h1 className="font-bold text-2xl">
|
|
||||||
<Link href={`/groups/${groupId}`}>{group.name}</Link>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-between">
|
|
||||||
<Suspense>
|
|
||||||
<GroupTabs groupId={groupId} />
|
|
||||||
</Suspense>
|
|
||||||
<ShareButton group={group} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function ReimbursementList({
|
|||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const t = useTranslations('Balances.Reimbursements')
|
const t = useTranslations('Balances.Reimbursements')
|
||||||
if (reimbursements.length === 0) {
|
if (reimbursements.length === 0) {
|
||||||
return <p className="px-6 text-sm pb-6">{t('noImbursements')}</p>
|
return <p className="text-sm pb-6">{t('noImbursements')}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
const getParticipant = (id: string) => participants.find((p) => p.id === id)
|
const getParticipant = (id: string) => participants.find((p) => p.id === id)
|
||||||
|
|||||||
27
src/app/groups/[groupId]/stats/page.client.tsx
Normal file
27
src/app/groups/[groupId]/stats/page.client.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Totals } from '@/app/groups/[groupId]/stats/totals'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
export function TotalsPageClient({ groupId }: { groupId: string }) {
|
||||||
|
const t = useTranslations('Stats')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('Totals.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('Totals.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col space-y-4">
|
||||||
|
<Totals groupId={groupId} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,5 @@
|
|||||||
import { cached } from '@/app/cached-functions'
|
import { TotalsPageClient } from '@/app/groups/[groupId]/stats/page.client'
|
||||||
import { Totals } from '@/app/groups/[groupId]/stats/totals'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { getGroupExpenses } from '@/lib/api'
|
|
||||||
import { getTotalGroupSpending } from '@/lib/totals'
|
|
||||||
import { Metadata } from 'next'
|
import { Metadata } from 'next'
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Totals',
|
title: 'Totals',
|
||||||
@@ -22,28 +10,5 @@ export default async function TotalsPage({
|
|||||||
}: {
|
}: {
|
||||||
params: { groupId: string }
|
params: { groupId: string }
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations('Stats')
|
return <TotalsPageClient groupId={groupId} />
|
||||||
const group = await cached.getGroup(groupId)
|
|
||||||
if (!group) notFound()
|
|
||||||
|
|
||||||
const expenses = await getGroupExpenses(groupId)
|
|
||||||
const totalGroupSpendings = getTotalGroupSpending(expenses)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('Totals.title')}</CardTitle>
|
|
||||||
<CardDescription>{t('Totals.description')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col space-y-4">
|
|
||||||
<Totals
|
|
||||||
group={group}
|
|
||||||
expenses={expenses}
|
|
||||||
totalGroupSpendings={totalGroupSpendings}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
|
||||||
import { getTotalActiveUserShare } from '@/lib/totals'
|
|
||||||
import { cn, formatCurrency } from '@/lib/utils'
|
import { cn, formatCurrency } from '@/lib/utils'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
type Props = {
|
export function TotalsYourShare({
|
||||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
totalParticipantShare = 0,
|
||||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
currency,
|
||||||
}
|
}: {
|
||||||
|
totalParticipantShare?: number
|
||||||
export function TotalsYourShare({ group, expenses }: Props) {
|
currency: string
|
||||||
|
}) {
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const t = useTranslations('Stats.Totals')
|
const t = useTranslations('Stats.Totals')
|
||||||
const [activeUser, setActiveUser] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
|
||||||
if (activeUser) setActiveUser(activeUser)
|
|
||||||
}, [group, expenses])
|
|
||||||
|
|
||||||
const totalActiveUserShare =
|
|
||||||
activeUser === '' || activeUser === 'None'
|
|
||||||
? 0
|
|
||||||
: getTotalActiveUserShare(activeUser, expenses)
|
|
||||||
const currency = group.currency
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -32,10 +18,10 @@ export function TotalsYourShare({ group, expenses }: Props) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-lg',
|
'text-lg',
|
||||||
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
|
totalParticipantShare < 0 ? 'text-green-600' : 'text-red-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(currency, Math.abs(totalActiveUserShare), locale)}
|
{formatCurrency(currency, Math.abs(totalParticipantShare), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
|
||||||
import { useActiveUser } from '@/lib/hooks'
|
|
||||||
import { getTotalActiveUserPaidFor } from '@/lib/totals'
|
|
||||||
import { cn, formatCurrency } from '@/lib/utils'
|
import { cn, formatCurrency } from '@/lib/utils'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
|
|
||||||
type Props = {
|
export function TotalsYourSpendings({
|
||||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
totalParticipantSpendings = 0,
|
||||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
currency,
|
||||||
}
|
}: {
|
||||||
|
totalParticipantSpendings?: number
|
||||||
export function TotalsYourSpendings({ group, expenses }: Props) {
|
currency: string
|
||||||
|
}) {
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const t = useTranslations('Stats.Totals')
|
const t = useTranslations('Stats.Totals')
|
||||||
const activeUser = useActiveUser(group.id)
|
|
||||||
|
|
||||||
const totalYourSpendings =
|
const balance =
|
||||||
activeUser === '' || activeUser === 'None'
|
totalParticipantSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
|
||||||
? 0
|
|
||||||
: getTotalActiveUserPaidFor(activeUser, expenses)
|
|
||||||
const currency = group.currency
|
|
||||||
const balance = totalYourSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -29,10 +22,10 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-lg',
|
'text-lg',
|
||||||
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
|
totalParticipantSpendings < 0 ? 'text-green-600' : 'text-red-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(currency, Math.abs(totalYourSpendings), locale)}
|
{formatCurrency(currency, Math.abs(totalParticipantSpendings), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,19 +2,36 @@
|
|||||||
import { TotalsGroupSpending } from '@/app/groups/[groupId]/stats/totals-group-spending'
|
import { TotalsGroupSpending } from '@/app/groups/[groupId]/stats/totals-group-spending'
|
||||||
import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
|
import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
|
||||||
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
|
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
|
||||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useActiveUser } from '@/lib/hooks'
|
import { useActiveUser } from '@/lib/hooks'
|
||||||
|
import { trpc } from '@/trpc/client'
|
||||||
|
|
||||||
export function Totals({
|
export function Totals({ groupId }: { groupId: string }) {
|
||||||
group,
|
const activeUser = useActiveUser(groupId)
|
||||||
expenses,
|
|
||||||
totalGroupSpendings,
|
const participantId =
|
||||||
}: {
|
activeUser && activeUser !== 'None' ? activeUser : undefined
|
||||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
const { data } = trpc.groups.stats.get.useQuery({ groupId, participantId })
|
||||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
|
||||||
totalGroupSpendings: number
|
|
||||||
}) {
|
if (!data || !groupData)
|
||||||
const activeUser = useActiveUser(group.id)
|
return (
|
||||||
|
<div className="flex flex-col gap-7">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<Skeleton className="mt-1 h-3 w-48" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
totalGroupSpendings,
|
||||||
|
totalParticipantShare,
|
||||||
|
totalParticipantSpendings,
|
||||||
|
} = data
|
||||||
|
const { group } = groupData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -22,10 +39,16 @@ export function Totals({
|
|||||||
totalGroupSpendings={totalGroupSpendings}
|
totalGroupSpendings={totalGroupSpendings}
|
||||||
currency={group.currency}
|
currency={group.currency}
|
||||||
/>
|
/>
|
||||||
{activeUser && activeUser !== 'None' && (
|
{participantId && (
|
||||||
<>
|
<>
|
||||||
<TotalsYourSpendings group={group} expenses={expenses} />
|
<TotalsYourSpendings
|
||||||
<TotalsYourShare group={group} expenses={expenses} />
|
totalParticipantSpendings={totalParticipantSpendings}
|
||||||
|
currency={group.currency}
|
||||||
|
/>
|
||||||
|
<TotalsYourShare
|
||||||
|
totalParticipantShare={totalParticipantShare}
|
||||||
|
currency={group.currency}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
'use client'
|
|
||||||
import { SubmitButton } from '@/components/submit-button'
|
import { SubmitButton } from '@/components/submit-button'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -175,7 +174,7 @@ export function GroupForm({
|
|||||||
<FormLabel>{t('InformationField.label')}</FormLabel>
|
<FormLabel>{t('InformationField.label')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={10}
|
rows={2}
|
||||||
className="text-base"
|
className="text-base"
|
||||||
{...field}
|
{...field}
|
||||||
placeholder={t('InformationField.placeholder')}
|
placeholder={t('InformationField.placeholder')}
|
||||||
|
|||||||
@@ -310,11 +310,34 @@ export async function getExpense(groupId: string, expenseId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActivities(groupId: string) {
|
export async function getActivities(
|
||||||
return prisma.activity.findMany({
|
groupId: string,
|
||||||
|
options?: { offset?: number; length?: number },
|
||||||
|
) {
|
||||||
|
const activities = await prisma.activity.findMany({
|
||||||
where: { groupId },
|
where: { groupId },
|
||||||
orderBy: [{ time: 'desc' }],
|
orderBy: [{ time: 'desc' }],
|
||||||
|
skip: options?.offset,
|
||||||
|
take: options?.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const expenseIds = activities
|
||||||
|
.map((activity) => activity.expenseId)
|
||||||
|
.filter(Boolean)
|
||||||
|
const expenses = await prisma.expense.findMany({
|
||||||
|
where: {
|
||||||
|
groupId,
|
||||||
|
id: { in: expenseIds },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return activities.map((activity) => ({
|
||||||
|
...activity,
|
||||||
|
expense:
|
||||||
|
activity.expenseId !== null
|
||||||
|
? expenses.find((expense) => expense.id === activity.expenseId)
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logActivity(
|
export async function logActivity(
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { categoriesRouter } from '@/trpc/routers/categories'
|
||||||
import { groupsRouter } from '@/trpc/routers/groups'
|
import { groupsRouter } from '@/trpc/routers/groups'
|
||||||
import { inferRouterOutputs } from '@trpc/server'
|
import { inferRouterOutputs } from '@trpc/server'
|
||||||
import { createTRPCRouter } from '../init'
|
import { createTRPCRouter } from '../init'
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
groups: groupsRouter,
|
groups: groupsRouter,
|
||||||
|
categories: categoriesRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter
|
||||||
|
|||||||
6
src/trpc/routers/categories/index.ts
Normal file
6
src/trpc/routers/categories/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { listCategoriesProcedure } from '@/trpc/routers/categories/list.procedure'
|
||||||
|
|
||||||
|
export const categoriesRouter = createTRPCRouter({
|
||||||
|
list: listCategoriesProcedure,
|
||||||
|
})
|
||||||
6
src/trpc/routers/categories/list.procedure.ts
Normal file
6
src/trpc/routers/categories/list.procedure.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { getCategories } from '@/lib/api'
|
||||||
|
import { baseProcedure } from '@/trpc/init'
|
||||||
|
|
||||||
|
export const listCategoriesProcedure = baseProcedure.query(async () => {
|
||||||
|
return { categories: await getCategories() }
|
||||||
|
})
|
||||||
6
src/trpc/routers/groups/activities/index.ts
Normal file
6
src/trpc/routers/groups/activities/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { listGroupActivitiesProcedure } from '@/trpc/routers/groups/activities/list.procedure'
|
||||||
|
|
||||||
|
export const activitiesRouter = createTRPCRouter({
|
||||||
|
list: listGroupActivitiesProcedure,
|
||||||
|
})
|
||||||
23
src/trpc/routers/groups/activities/list.procedure.ts
Normal file
23
src/trpc/routers/groups/activities/list.procedure.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getActivities } from '@/lib/api'
|
||||||
|
import { baseProcedure } from '@/trpc/init'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const listGroupActivitiesProcedure = baseProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string(),
|
||||||
|
cursor: z.number().optional().default(0),
|
||||||
|
limit: z.number().optional().default(5),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input: { groupId, cursor, limit } }) => {
|
||||||
|
const activities = await getActivities(groupId, {
|
||||||
|
offset: cursor,
|
||||||
|
length: limit + 1,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
activities: activities.slice(0, limit),
|
||||||
|
hasMore: !!activities[limit],
|
||||||
|
nextCursor: cursor + limit,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { getGroup } from '@/lib/api'
|
import { getGroup, getGroupExpensesParticipants } from '@/lib/api'
|
||||||
import { baseProcedure } from '@/trpc/init'
|
import { baseProcedure } from '@/trpc/init'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const getGroupInformationProcedure = baseProcedure
|
export const getGroupProcedure = baseProcedure
|
||||||
.input(
|
.input(z.object({ groupId: z.string().min(1) }))
|
||||||
z.object({
|
|
||||||
groupId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ input: { groupId } }) => {
|
.query(async ({ input: { groupId } }) => {
|
||||||
const group = await getGroup(groupId)
|
const group = await getGroup(groupId)
|
||||||
if (!group) {
|
if (!group) {
|
||||||
@@ -17,5 +13,7 @@ export const getGroupInformationProcedure = baseProcedure
|
|||||||
message: 'Group not found.',
|
message: 'Group not found.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { information: group.information ?? '' }
|
|
||||||
|
const participantsWithExpenses = await getGroupExpensesParticipants(groupId)
|
||||||
|
return { group, participantsWithExpenses }
|
||||||
})
|
})
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { createTRPCRouter } from '@/trpc/init'
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { activitiesRouter } from '@/trpc/routers/groups/activities'
|
||||||
import { groupBalancesRouter } from '@/trpc/routers/groups/balances'
|
import { groupBalancesRouter } from '@/trpc/routers/groups/balances'
|
||||||
import { groupExpensesRouter } from '@/trpc/routers/groups/expenses'
|
import { groupExpensesRouter } from '@/trpc/routers/groups/expenses'
|
||||||
import { groupInformationRouter } from '@/trpc/routers/groups/information'
|
import { getGroupProcedure } from '@/trpc/routers/groups/get.procedure'
|
||||||
|
import { groupStatsRouter } from '@/trpc/routers/groups/stats'
|
||||||
|
import { updateGroupProcedure } from '@/trpc/routers/groups/update.procedure'
|
||||||
|
|
||||||
export const groupsRouter = createTRPCRouter({
|
export const groupsRouter = createTRPCRouter({
|
||||||
expenses: groupExpensesRouter,
|
expenses: groupExpensesRouter,
|
||||||
balances: groupBalancesRouter,
|
balances: groupBalancesRouter,
|
||||||
information: groupInformationRouter,
|
stats: groupStatsRouter,
|
||||||
|
activities: activitiesRouter,
|
||||||
|
|
||||||
|
get: getGroupProcedure,
|
||||||
|
update: updateGroupProcedure,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { createTRPCRouter } from '@/trpc/init'
|
|
||||||
import { getGroupInformationProcedure } from '@/trpc/routers/groups/information/get.procedure'
|
|
||||||
|
|
||||||
export const groupInformationRouter = createTRPCRouter({
|
|
||||||
get: getGroupInformationProcedure,
|
|
||||||
})
|
|
||||||
35
src/trpc/routers/groups/stats/get.procedure.ts
Normal file
35
src/trpc/routers/groups/stats/get.procedure.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { getGroupExpenses } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
getTotalActiveUserPaidFor,
|
||||||
|
getTotalActiveUserShare,
|
||||||
|
getTotalGroupSpending,
|
||||||
|
} from '@/lib/totals'
|
||||||
|
import { baseProcedure } from '@/trpc/init'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const getGroupStatsProcedure = baseProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string().min(1),
|
||||||
|
participantId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input: { groupId, participantId } }) => {
|
||||||
|
const expenses = await getGroupExpenses(groupId)
|
||||||
|
const totalGroupSpendings = getTotalGroupSpending(expenses)
|
||||||
|
|
||||||
|
const totalParticipantSpendings =
|
||||||
|
participantId !== undefined
|
||||||
|
? getTotalActiveUserPaidFor(participantId, expenses)
|
||||||
|
: undefined
|
||||||
|
const totalParticipantShare =
|
||||||
|
participantId !== undefined
|
||||||
|
? getTotalActiveUserShare(participantId, expenses)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalGroupSpendings,
|
||||||
|
totalParticipantSpendings,
|
||||||
|
totalParticipantShare,
|
||||||
|
}
|
||||||
|
})
|
||||||
6
src/trpc/routers/groups/stats/index.ts
Normal file
6
src/trpc/routers/groups/stats/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createTRPCRouter } from '@/trpc/init'
|
||||||
|
import { getGroupStatsProcedure } from '@/trpc/routers/groups/stats/get.procedure'
|
||||||
|
|
||||||
|
export const groupStatsRouter = createTRPCRouter({
|
||||||
|
get: getGroupStatsProcedure,
|
||||||
|
})
|
||||||
16
src/trpc/routers/groups/update.procedure.ts
Normal file
16
src/trpc/routers/groups/update.procedure.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { updateGroup } from '@/lib/api'
|
||||||
|
import { groupFormSchema } from '@/lib/schemas'
|
||||||
|
import { baseProcedure } from '@/trpc/init'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const updateGroupProcedure = baseProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
groupId: z.string().min(1),
|
||||||
|
groupFormValues: groupFormSchema,
|
||||||
|
participantId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input: { groupId, groupFormValues, participantId } }) => {
|
||||||
|
await updateGroup(groupId, groupFormValues, participantId)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user