mirror of
https://github.com/spliit-app/spliit.git
synced 2026-03-06 04:26:13 +01:00
Add basic activity log (#141)
* Add basic activity log * Add database migration * Fix layout * Fix types --------- Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
This commit is contained in:
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
102
src/app/groups/[groupId]/activity/activity-item.tsx
Normal file
102
src/app/groups/[groupId]/activity/activity-item.tsx
Normal 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>“{expense}”</em> created by{' '}
|
||||||
|
<strong>{participant}</strong>.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Expense <em>“{expense}”</em> updated by{' '}
|
||||||
|
<strong>{participant}</strong>.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Expense <em>“{expense}”</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
src/app/groups/[groupId]/activity/activity-list.tsx
Normal file
112
src/app/groups/[groupId]/activity/activity-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/app/groups/[groupId]/activity/page.tsx
Normal file
51
src/app/groups/[groupId]/activity/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-b
|
|||||||
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
|
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { getGroupExpenses } from '@/lib/api'
|
import { getGroupExpenses } from '@/lib/api'
|
||||||
import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils'
|
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@@ -60,7 +60,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
|
|||||||
{formatCurrency(currency, expense.amount)}
|
{formatCurrency(currency, expense.amount)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatExpenseDate(expense.expenseDate)}
|
{formatDate(expense.expenseDate, { dateStyle: 'medium' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +57,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,10 +236,11 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -722,7 +724,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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { prisma } 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() {
|
||||||
@@ -29,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}`)
|
||||||
@@ -41,9 +42,16 @@ export async function createExpense(
|
|||||||
throw new Error(`Invalid participant ID: ${participant}`)
|
throw new Error(`Invalid participant ID: ${participant}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -75,7 +83,18 @@ export async function createExpense(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteExpense(expenseId: string) {
|
export async function deleteExpense(
|
||||||
|
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 },
|
||||||
@@ -110,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}`)
|
||||||
@@ -125,6 +145,12 @@ export async function updateExpense(
|
|||||||
throw new Error(`Invalid participant ID: ${participant}`)
|
throw new Error(`Invalid participant ID: ${participant}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
@@ -189,10 +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')
|
||||||
|
|
||||||
|
await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId })
|
||||||
|
|
||||||
return prisma.group.update({
|
return prisma.group.update({
|
||||||
where: { id: groupId },
|
where: { id: groupId },
|
||||||
data: {
|
data: {
|
||||||
@@ -273,3 +302,25 @@ export async function getExpense(groupId: string, expenseId: string) {
|
|||||||
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user